选课
This commit is contained in:
@@ -337,12 +337,13 @@ public sealed class CourseSelectionsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("offerings/{id:guid}/eligible-students")]
|
[HttpGet("offerings/{id:guid}/eligible-students")]
|
||||||
[Authorize(Roles = RoundManagers)]
|
[Authorize(Roles = OfferingManagers)]
|
||||||
public async Task<ActionResult> GetEligibleStudents(
|
public async Task<ActionResult> GetEligibleStudents(
|
||||||
Guid id,
|
Guid id,
|
||||||
string? keyword = null,
|
string? keyword = null,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
|
bool forceMode = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
page = Math.Max(1, page);
|
page = Math.Max(1, page);
|
||||||
@@ -360,10 +361,10 @@ public sealed class CourseSelectionsController(
|
|||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (offering is null) return NotFound();
|
if (offering is null) return NotFound();
|
||||||
if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
||||||
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||||
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
||||||
return ConflictProblem("选课批次开放后才能办理管理员代选。");
|
return ConflictProblem("选课批次开放后才能办理。");
|
||||||
|
|
||||||
var source = db.Students.AsNoTracking()
|
var source = db.Students.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -567,6 +568,88 @@ public sealed class CourseSelectionsController(
|
|||||||
return Ok(new { EnrolledCount = students.Count });
|
return Ok(new { EnrolledCount = students.Count });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("offerings/{offeringId:guid}/force-enroll")]
|
||||||
|
[Authorize(Roles = OfferingManagers)]
|
||||||
|
public async Task<ActionResult> ForceEnroll(
|
||||||
|
Guid offeringId,
|
||||||
|
ForceEnrollmentRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var studentIds = request.StudentIds.Distinct().ToArray();
|
||||||
|
if (studentIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一名学生。");
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
||||||
[Authorize(Roles = RoundManagers)]
|
[Authorize(Roles = RoundManagers)]
|
||||||
public async Task<ActionResult> AdminWithdraw(
|
public async Task<ActionResult> AdminWithdraw(
|
||||||
@@ -638,6 +721,12 @@ public sealed class CourseSelectionsController(
|
|||||||
.Select(item => (CourseEnrollmentStatus?)item.Status)
|
.Select(item => (CourseEnrollmentStatus?)item.Status)
|
||||||
.FirstOrDefault(),
|
.FirstOrDefault(),
|
||||||
x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible,
|
x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible,
|
||||||
|
db.CourseEnrollments.Any(e =>
|
||||||
|
e.StudentId == student.Id &&
|
||||||
|
e.CourseSelectionOffering!.TeachingTask!.CourseId ==
|
||||||
|
x.TeachingTask.CourseId &&
|
||||||
|
e.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
|
||||||
|
round.AcademicTermId),
|
||||||
db.ScheduleEntries
|
db.ScheduleEntries
|
||||||
.Where(entry =>
|
.Where(entry =>
|
||||||
entry.TeachingTaskId == x.TeachingTaskId &&
|
entry.TeachingTaskId == x.TeachingTaskId &&
|
||||||
@@ -716,6 +805,7 @@ public sealed class CourseSelectionsController(
|
|||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
.Select(item => item.Teacher!.Name),
|
.Select(item => item.Teacher!.Name),
|
||||||
x.Status,
|
x.Status,
|
||||||
|
x.EnrollmentType,
|
||||||
x.EnrolledAt,
|
x.EnrolledAt,
|
||||||
x.WithdrawnAt,
|
x.WithdrawnAt,
|
||||||
CanWithdraw =
|
CanWithdraw =
|
||||||
@@ -762,6 +852,15 @@ public sealed class CourseSelectionsController(
|
|||||||
x.AdministrativeClassId == student.AdministrativeClassId))
|
x.AdministrativeClassId == student.AdministrativeClassId))
|
||||||
return Forbid();
|
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(
|
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
|
||||||
x =>
|
x =>
|
||||||
x.CourseSelectionOfferingId == offering.Id &&
|
x.CourseSelectionOfferingId == offering.Id &&
|
||||||
@@ -769,14 +868,21 @@ public sealed class CourseSelectionsController(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
|
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
return ConflictProblem("你已经选择了该教学班。");
|
return ConflictProblem("你已经选择了该教学班。");
|
||||||
|
|
||||||
var enrolledCount = await db.CourseEnrollments.CountAsync(
|
var enrolledCount = await db.CourseEnrollments.CountAsync(
|
||||||
x =>
|
x =>
|
||||||
x.CourseSelectionOfferingId == offering.Id &&
|
x.CourseSelectionOfferingId == offering.Id &&
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled,
|
x.Status == CourseEnrollmentStatus.Enrolled,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (enrolledCount >= offering.Capacity)
|
var effectiveCapacity = isRetake
|
||||||
|
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
|
||||||
|
: offering.Capacity;
|
||||||
|
if (enrolledCount >= effectiveCapacity)
|
||||||
return ConflictProblem("该教学班名额已满。");
|
return ConflictProblem("该教学班名额已满。");
|
||||||
|
|
||||||
|
// Normal enrollment: no duplicate course in same term
|
||||||
|
if (!isRetake)
|
||||||
|
{
|
||||||
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
|
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
|
||||||
x =>
|
x =>
|
||||||
x.StudentId == student.Id &&
|
x.StudentId == student.Id &&
|
||||||
@@ -787,7 +893,9 @@ public sealed class CourseSelectionsController(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (duplicateCourse)
|
if (duplicateCourse)
|
||||||
return ConflictProblem("同一学期不能重复选择相同课程。");
|
return ConflictProblem("同一学期不能重复选择相同课程。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit limit check
|
||||||
var selectedCredits = await db.CourseEnrollments
|
var selectedCredits = await db.CourseEnrollments
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.StudentId == student.Id &&
|
x.StudentId == student.Id &&
|
||||||
@@ -799,16 +907,17 @@ public sealed class CourseSelectionsController(
|
|||||||
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
|
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
|
||||||
{
|
{
|
||||||
return ConflictProblem(
|
return ConflictProblem(
|
||||||
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
||||||
|
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule conflict check
|
||||||
var candidateEntries = await PublishedScheduleEntries(
|
var candidateEntries = await PublishedScheduleEntries(
|
||||||
round.AcademicTermId,
|
round.AcademicTermId, [task.Id], cancellationToken);
|
||||||
[task.Id],
|
|
||||||
cancellationToken);
|
|
||||||
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||||
candidateEntries.Count == 0)
|
candidateEntries.Count == 0)
|
||||||
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
||||||
|
|
||||||
var selectedTaskIds = await db.CourseEnrollments
|
var selectedTaskIds = await db.CourseEnrollments
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.StudentId == student.Id &&
|
x.StudentId == student.Id &&
|
||||||
@@ -819,18 +928,28 @@ public sealed class CourseSelectionsController(
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArrayAsync(cancellationToken);
|
.ToArrayAsync(cancellationToken);
|
||||||
var selectedEntries = await PublishedScheduleEntries(
|
var selectedEntries = await PublishedScheduleEntries(
|
||||||
round.AcademicTermId,
|
round.AcademicTermId, selectedTaskIds, cancellationToken);
|
||||||
selectedTaskIds,
|
|
||||||
cancellationToken);
|
|
||||||
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
|
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
|
||||||
|
{
|
||||||
|
if (!isRetake)
|
||||||
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
|
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
|
||||||
|
|
||||||
|
// Retake: allow ≤50% overlap
|
||||||
|
var overlap = CourseSelectionRules.CalculateScheduleOverlap(
|
||||||
|
candidateEntries, selectedEntries);
|
||||||
|
if (overlap > 50)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。");
|
||||||
|
}
|
||||||
|
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
existing = new CourseEnrollment
|
existing = new CourseEnrollment
|
||||||
{
|
{
|
||||||
CourseSelectionOfferingId = offering.Id,
|
CourseSelectionOfferingId = offering.Id,
|
||||||
StudentId = student.Id
|
StudentId = student.Id,
|
||||||
|
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal
|
||||||
};
|
};
|
||||||
db.CourseEnrollments.Add(existing);
|
db.CourseEnrollments.Add(existing);
|
||||||
}
|
}
|
||||||
@@ -839,10 +958,11 @@ public sealed class CourseSelectionsController(
|
|||||||
existing.Status = CourseEnrollmentStatus.Enrolled;
|
existing.Status = CourseEnrollmentStatus.Enrolled;
|
||||||
existing.EnrolledAt = now;
|
existing.EnrolledAt = now;
|
||||||
existing.WithdrawnAt = null;
|
existing.WithdrawnAt = null;
|
||||||
|
existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
return Created(string.Empty, new { existing.Id });
|
return Created(string.Empty, new { existing.Id, IsRetake = isRetake });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("student/enrollments/{id:guid}")]
|
[HttpDelete("student/enrollments/{id:guid}")]
|
||||||
@@ -1096,6 +1216,9 @@ public sealed record StudentEnrollmentRequest(Guid OfferingId);
|
|||||||
public sealed record AdminEnrollmentRequest(
|
public sealed record AdminEnrollmentRequest(
|
||||||
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
|
public sealed record ForceEnrollmentRequest(
|
||||||
|
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
public sealed record StudentOfferingDto(
|
public sealed record StudentOfferingDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid TeachingTaskId,
|
Guid TeachingTaskId,
|
||||||
@@ -1109,6 +1232,7 @@ public sealed record StudentOfferingDto(
|
|||||||
bool IsOpenToAll,
|
bool IsOpenToAll,
|
||||||
CourseEnrollmentStatus? EnrollmentStatus,
|
CourseEnrollmentStatus? EnrollmentStatus,
|
||||||
bool IsFlexible,
|
bool IsFlexible,
|
||||||
|
bool IsRetake,
|
||||||
IEnumerable<StudentScheduleDto> Schedules);
|
IEnumerable<StudentScheduleDto> Schedules);
|
||||||
|
|
||||||
public sealed record StudentScheduleDto(
|
public sealed record StudentScheduleDto(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public sealed class CourseEnrollment : EntityBase
|
|||||||
public Guid StudentId { get; set; }
|
public Guid StudentId { get; set; }
|
||||||
public Student? Student { get; set; }
|
public Student? Student { get; set; }
|
||||||
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
|
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
|
||||||
|
public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal;
|
||||||
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
|
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? WithdrawnAt { get; set; }
|
public DateTime? WithdrawnAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -52,3 +53,9 @@ public enum CourseEnrollmentStatus
|
|||||||
Enrolled = 1,
|
Enrolled = 1,
|
||||||
Withdrawn = 2
|
Withdrawn = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum EnrollmentType
|
||||||
|
{
|
||||||
|
Normal = 1,
|
||||||
|
Retake = 2
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,4 +26,46 @@ public static class CourseSelectionRules
|
|||||||
candidateEntries.Any(candidate =>
|
candidateEntries.Any(candidate =>
|
||||||
selectedEntries.Any(selected =>
|
selectedEntries.Any(selected =>
|
||||||
ScheduleConflictDetector.TimeOverlaps(candidate, selected)));
|
ScheduleConflictDetector.TimeOverlaps(candidate, selected)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the overlap percentage of candidate schedule entries with selected entries.
|
||||||
|
/// 0 = no conflict, 100 = fully overlapping.
|
||||||
|
/// Used for retake enrollment where ≤50% overlap is allowed.
|
||||||
|
/// </summary>
|
||||||
|
public static double CalculateScheduleOverlap(
|
||||||
|
IReadOnlyCollection<ScheduleEntry> candidateEntries,
|
||||||
|
IReadOnlyCollection<ScheduleEntry> selectedEntries)
|
||||||
|
{
|
||||||
|
if (candidateEntries.Count == 0 || selectedEntries.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
int totalCandidatePeriods = 0;
|
||||||
|
int overlappedPeriods = 0;
|
||||||
|
|
||||||
|
foreach (var candidate in candidateEntries)
|
||||||
|
{
|
||||||
|
totalCandidatePeriods += candidate.PeriodCount;
|
||||||
|
foreach (var selected in selectedEntries)
|
||||||
|
{
|
||||||
|
if (!ScheduleConflictDetector.TimeOverlaps(candidate, selected))
|
||||||
|
continue;
|
||||||
|
// Calculate overlapping periods
|
||||||
|
var overlapStart = Math.Max(
|
||||||
|
candidate.StartPeriod, selected.StartPeriod);
|
||||||
|
var overlapEnd = Math.Min(
|
||||||
|
candidate.StartPeriod + candidate.PeriodCount,
|
||||||
|
selected.StartPeriod + selected.PeriodCount);
|
||||||
|
if (overlapEnd > overlapStart)
|
||||||
|
overlappedPeriods += overlapEnd - overlapStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalCandidatePeriods == 0
|
||||||
|
? 0
|
||||||
|
: (double)overlappedPeriods / totalCandidatePeriods * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
|
||||||
|
public static int RetakeCapacity(int originalCapacity) =>
|
||||||
|
(int)Math.Ceiling(originalCapacity * 1.15);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260725_19_flexible_grades";
|
"20260725_19_flexible_grades";
|
||||||
private const string ExamSchedulingOptimizationMigration =
|
private const string ExamSchedulingOptimizationMigration =
|
||||||
"20260725_20_exam_scheduling_optimization";
|
"20260725_20_exam_scheduling_optimization";
|
||||||
|
private const string RetakeEnrollmentMigration =
|
||||||
|
"20260725_21_retake_enrollment";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -225,6 +227,19 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
? []
|
? []
|
||||||
: ExamSchedulingOptimizationStatements,
|
: ExamSchedulingOptimizationStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var retakeEnrollmentExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('CourseEnrollments')
|
||||||
|
WHERE name = 'EnrollmentType'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
RetakeEnrollmentMigration,
|
||||||
|
retakeEnrollmentExists ? [] : RetakeEnrollmentStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1461,4 +1476,12 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "ExamSessions" ("RequiredBuildingId");
|
ON "ExamSessions" ("RequiredBuildingId");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] RetakeEnrollmentStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "CourseEnrollments"
|
||||||
|
ADD COLUMN "EnrollmentType" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RetakeEnrollment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "EnrollmentType",
|
||||||
|
table: "CourseEnrollments",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnrollmentType",
|
||||||
|
table: "CourseEnrollments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,16 +125,44 @@ function conflictingSelectedCourses(offering: any) {
|
|||||||
function offeringBlockReason(offering: any) {
|
function offeringBlockReason(offering: any) {
|
||||||
if (offering.enrollmentStatus === 'Enrolled') return ''
|
if (offering.enrollmentStatus === 'Enrolled') return ''
|
||||||
if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内'
|
if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内'
|
||||||
if (offering.enrolledCount >= offering.capacity) return '教学班名额已满'
|
const effectiveCap = offering.isRetake
|
||||||
|
? Math.ceil(offering.capacity * 1.15)
|
||||||
|
: offering.capacity
|
||||||
|
if (offering.enrolledCount >= effectiveCap) return '教学班名额已满'
|
||||||
if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布'
|
if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布'
|
||||||
if (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 (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} 学分上限`
|
||||||
}
|
}
|
||||||
const conflicts = conflictingSelectedCourses(offering)
|
const conflicts = conflictingSelectedCourses(offering)
|
||||||
return conflicts.length ? `与已选“${conflicts.join('、')}”时间冲突` : ''
|
if (conflicts.length) {
|
||||||
|
if (!offering.isRetake) return `与已选”${conflicts.join('、')}”时间冲突`
|
||||||
|
// Retake: calculate overlap (client-side rough estimate)
|
||||||
|
if (!calcRetakeOverlapOk(offering)) return `重修时间冲突超过 50%,无法选课`
|
||||||
|
return '' // retake with acceptable overlap
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRetakeOverlapOk(offering: any): boolean {
|
||||||
|
let totalPeriods = 0, overlapPeriods = 0
|
||||||
|
for (const candidate of offering.schedules) {
|
||||||
|
totalPeriods += candidate.periodCount
|
||||||
|
for (const selected of selectedOfferings.value) {
|
||||||
|
for (const existing of (selected.schedules ?? [])) {
|
||||||
|
if (candidate.dayOfWeek !== existing.dayOfWeek) continue
|
||||||
|
const overlapStart = Math.max(candidate.startPeriod, existing.startPeriod)
|
||||||
|
const overlapEnd = Math.min(
|
||||||
|
candidate.startPeriod + candidate.periodCount,
|
||||||
|
existing.startPeriod + existing.periodCount,
|
||||||
|
)
|
||||||
|
if (overlapEnd > overlapStart) overlapPeriods += overlapEnd - overlapStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalPeriods === 0 || (overlapPeriods / totalPeriods * 100) <= 50
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredOfferings = computed(() => {
|
const filteredOfferings = computed(() => {
|
||||||
@@ -545,6 +573,63 @@ async function proxyEnroll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const forceDialog = ref(false)
|
||||||
|
const forceSubmitting = ref(false)
|
||||||
|
|
||||||
|
function openForceEnrollment() {
|
||||||
|
studentKeyword.value = ''
|
||||||
|
selectedStudentIds.value = []
|
||||||
|
eligiblePage.value = 1
|
||||||
|
forceDialog.value = true
|
||||||
|
loadForceEligibleStudents()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadForceEligibleStudents(page = eligiblePage.value) {
|
||||||
|
if (!roster.value) return
|
||||||
|
eligibleLoading.value = true
|
||||||
|
eligiblePage.value = page
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(
|
||||||
|
`/course-selections/offerings/${roster.value.id}/eligible-students`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
keyword: studentKeyword.value.trim() || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
eligibleStudents.value = data.items
|
||||||
|
eligibleTotal.value = data.total
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
eligibleLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnrollSubmit() {
|
||||||
|
if (!roster.value || selectedStudentIds.value.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一名学生。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
forceSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.post(
|
||||||
|
`/course-selections/offerings/${roster.value.id}/force-enroll`,
|
||||||
|
{ studentIds: selectedStudentIds.value },
|
||||||
|
)
|
||||||
|
ElMessage.success(`已强制选入 ${data.enrolledCount} 名学生(忽略所有限制)`)
|
||||||
|
forceDialog.value = false
|
||||||
|
await loadRoster(roster.value.id)
|
||||||
|
if (selectedRound.value) await selectRound(selectedRound.value)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
forceSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeFromRoster(student: any) {
|
async function removeFromRoster(student: any) {
|
||||||
if (!roster.value) return
|
if (!roster.value) return
|
||||||
try {
|
try {
|
||||||
@@ -894,7 +979,10 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
||||||
<h3>{{ offering.courseName }}</h3>
|
<h3>{{ offering.courseName }}</h3>
|
||||||
<p>{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分</p>
|
<p>
|
||||||
|
{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分
|
||||||
|
<el-tag v-if="offering.isRetake && offering.enrollmentStatus !== 'Enrolled'" size="small" type="warning" effect="plain" style="margin-left:6px">重修</el-tag>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<i v-if="offering.enrollmentStatus === 'Enrolled'" class="selected-mark">
|
<i v-if="offering.enrollmentStatus === 'Enrolled'" class="selected-mark">
|
||||||
<el-icon><CircleCheck /></el-icon> 已选
|
<el-icon><CircleCheck /></el-icon> 已选
|
||||||
@@ -918,8 +1006,14 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<div class="seat-meter">
|
<div class="seat-meter">
|
||||||
|
<template v-if="offering.isRetake && offering.enrollmentStatus !== 'Enrolled'">
|
||||||
|
<span>剩余 {{ Math.max(0, Math.ceil(offering.capacity * 1.15) - offering.enrolledCount) }} / {{ Math.ceil(offering.capacity * 1.15) }} 席 <em>(重修扩容)</em></span>
|
||||||
|
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / (Math.ceil(offering.capacity * 1.15)) * 100)}%` }" /></div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<span>剩余 {{ Math.max(0, offering.capacity - offering.enrolledCount) }} / {{ offering.capacity }} 席</span>
|
<span>剩余 {{ Math.max(0, offering.capacity - offering.enrolledCount) }} / {{ offering.capacity }} 席</span>
|
||||||
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / offering.capacity * 100)}%` }" /></div>
|
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / offering.capacity * 100)}%` }" /></div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="offering.schedules.length || offering.isFlexible"
|
v-if="offering.schedules.length || offering.isFlexible"
|
||||||
@@ -1045,12 +1139,20 @@ onMounted(async () => {
|
|||||||
<b>{{ roster.taskName }}</b>
|
<b>{{ roster.taskName }}</b>
|
||||||
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} 人</small>
|
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} 人</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="roster-actions">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="roster.canProxyEnroll"
|
v-if="roster.canProxyEnroll"
|
||||||
type="primary"
|
type="primary"
|
||||||
:icon="Plus"
|
:icon="Plus"
|
||||||
@click="openProxyEnrollment"
|
@click="openProxyEnrollment"
|
||||||
>代选学生</el-button>
|
>代选学生</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="isManager"
|
||||||
|
type="warning"
|
||||||
|
:icon="Plus"
|
||||||
|
@click="openForceEnrollment"
|
||||||
|
>强制选课</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="roster.canProxyEnroll"
|
v-if="roster.canProxyEnroll"
|
||||||
@@ -1059,6 +1161,13 @@ onMounted(async () => {
|
|||||||
:closable="false"
|
:closable="false"
|
||||||
title="公共必修课支持校级教务代选;系统仍会校验教学班容量、学分上限、重复课程和课表冲突。"
|
title="公共必修课支持校级教务代选;系统仍会校验教学班容量、学分上限、重复课程和课表冲突。"
|
||||||
/>
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="isManager"
|
||||||
|
class="roster-notice force-notice"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。"
|
||||||
|
/>
|
||||||
<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" />
|
||||||
@@ -1129,5 +1238,71 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="forceDialog" title="强制选课(忽略所有限制)" width="760px">
|
||||||
|
<template v-if="roster">
|
||||||
|
<div class="proxy-course-note">
|
||||||
|
<b>{{ roster.courseName }}</b>
|
||||||
|
<span>{{ roster.taskNumber }} · 当前 {{ roster.enrolledCount }} / {{ roster.capacity }} 人</span>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
class="roster-notice"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
title="强制选课将忽略容量、时间冲突、学分上限、重复课程等全部限制,请谨慎操作。"
|
||||||
|
/>
|
||||||
|
<div class="proxy-search" style="margin-top:12px">
|
||||||
|
<el-input
|
||||||
|
v-model="studentKeyword"
|
||||||
|
clearable
|
||||||
|
:prefix-icon="Search"
|
||||||
|
placeholder="学号、姓名或班级"
|
||||||
|
@keyup.enter="loadForceEligibleStudents(1)"
|
||||||
|
@clear="loadForceEligibleStudents(1)"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="loadForceEligibleStudents(1)">查询学生</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="eligibleLoading"
|
||||||
|
:data="eligibleStudents"
|
||||||
|
row-key="id"
|
||||||
|
height="360"
|
||||||
|
@selection-change="onEligibleSelectionChanged"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
|
<el-table-column prop="className" label="行政班" min-width="150" />
|
||||||
|
<el-table-column prop="collegeName" label="学院" min-width="120" />
|
||||||
|
<template #empty><el-empty description="没有可强制选课的在籍学生" /></template>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="eligibleTotal > 20"
|
||||||
|
class="proxy-pagination"
|
||||||
|
layout="prev, pager, next, total"
|
||||||
|
:current-page="eligiblePage"
|
||||||
|
:page-size="20"
|
||||||
|
:total="eligibleTotal"
|
||||||
|
@current-change="loadForceEligibleStudents"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="proxy-dialog-footer">
|
||||||
|
<span class="proxy-selected-count">已选择 {{ selectedStudentIds.length }} 人</span>
|
||||||
|
<el-button @click="forceDialog = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
:loading="forceSubmitting"
|
||||||
|
:disabled="selectedStudentIds.length === 0"
|
||||||
|
@click="forceEnrollSubmit"
|
||||||
|
>确认强制选课</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.roster-actions { display: flex; gap: 8px; }
|
||||||
|
.force-notice { margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -504,14 +504,14 @@ onMounted(async () => {
|
|||||||
<span v-else>{{ row.finalScore ?? '—' }}</span>
|
<span v-else>{{ row.finalScore ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="总评" width="110">
|
<el-table-column label="总评" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="total-score-cell">
|
<template v-if="detail.canEdit && row.examStatus === 'Normal'">
|
||||||
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
<b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))">
|
||||||
<span v-if="detail.canEdit && row.examStatus === 'Normal'" class="preview-score" :class="scoreClass(calcPreviewTotal(row))">
|
{{ calcPreviewTotal(row) ?? '—' }}
|
||||||
参考 {{ calcPreviewTotal(row) ?? '—' }}
|
</b>
|
||||||
</span>
|
</template>
|
||||||
</div>
|
<b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="考试状态" width="115">
|
<el-table-column label="考试状态" width="115">
|
||||||
@@ -631,11 +631,13 @@ onMounted(async () => {
|
|||||||
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
|
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
|
||||||
.add-item-row { display: flex; align-items: center; gap: 8px; }
|
.add-item-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
/* Preview score */
|
/* Preview total score */
|
||||||
.total-score-cell { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
.total-score.preview {
|
||||||
.preview-score { font-size: 10px; opacity: .7; white-space: nowrap; }
|
text-decoration: underline dashed;
|
||||||
.preview-score.failed { color: #b34e48; }
|
text-underline-offset: 3px;
|
||||||
.preview-score.excellent { color: #2d8975; }
|
text-decoration-color: var(--muted);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
/* College review */
|
/* College review */
|
||||||
.college-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
.college-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user