选课批次:时间窗口、退课截止、学分上限、开放/关闭。

教学班投放:容量、班级范围、全校开放。
学生选退课:容量、重复课程、学分上限、课表冲突校验。
教学班实时名单。
校级、学院级、学生角色分级操作。
SQLite 本地增量升级及演示数据。
MySQL 正式 EF Core 迁移。
Vue 已编译进 wwwroot,单服务运行无需 npm run dev。
学生端和管理端均完成桌面、手机响应式检查。
This commit is contained in:
2026-07-24 15:17:26 +08:00 Unverified
parent bcc4d33bd5
commit 151a22554f
19 changed files with 3972 additions and 6 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
+64
View File
@@ -91,6 +91,24 @@ try {
-Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" `
-Headers $headers
}
$selectionRounds = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/course-selections/rounds' `
-Headers $headers
if (@($selectionRounds).Count -lt 1) {
throw 'Development course-selection round was not seeded.'
}
$activeSelectionRound = @($selectionRounds) |
Where-Object { $_.isAvailableNow } |
Select-Object -First 1
if ($null -eq $activeSelectionRound) {
throw 'No course-selection round is open for the smoke test.'
}
$selectionOfferings = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/rounds/$($activeSelectionRound.id)/offerings" `
-Headers $headers
if (@($selectionOfferings).Count -lt 1) {
throw 'Development course-selection offering was not seeded.'
}
$managedUsers = Invoke-RestMethod -Uri 'http://localhost:5255/api/users' -Headers $headers
$teacherAccount = @($managedUsers) |
Where-Object { $_.userName -eq 'teacher' } |
@@ -162,6 +180,48 @@ try {
"$($scenario.UserName):$($scenario.Scope)"
}
$studentLoginBody = @{
userName = 'student'
password = 'Student@123456'
} | ConvertTo-Json
$studentLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $studentLoginBody
$studentHeaders = @{ Authorization = "Bearer $($studentLogin.token)" }
$studentOptions = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/student/options?roundId=$($activeSelectionRound.id)" `
-Headers $studentHeaders
$studentOffering = @($studentOptions.offerings) | Select-Object -First 1
if ($null -eq $studentOffering) {
throw 'Student has no eligible course-selection offering.'
}
if ($studentOffering.enrollmentStatus -ne 'Enrolled') {
$enrollmentBody = @{ offeringId = $studentOffering.id } | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/course-selections/student/enrollments' `
-Headers $studentHeaders `
-ContentType 'application/json' `
-Body $enrollmentBody |
Out-Null
}
$studentEnrollments = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/student/enrollments?academicTermId=$($activeSelectionRound.academicTermId)" `
-Headers $studentHeaders
$activeEnrollments = @($studentEnrollments) |
Where-Object { $_.status -eq 'Enrolled' }
if ($activeEnrollments.Count -lt 1) {
throw 'Student course enrollment was not persisted.'
}
$selectionRoster = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/offerings/$($studentOffering.id)/roster" `
-Headers $headers
if ($selectionRoster.enrolledCount -lt 1) {
throw 'Course-selection roster did not include the selected student.'
}
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
$unknownApiParameters = @{
@@ -186,6 +246,10 @@ try {
TeachingTasks = $teachingTasks.total
Schedules = @($schedulePlans).Count
ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 }
SelectionRounds = @($selectionRounds).Count
SelectionOfferings = @($selectionOfferings).Count
StudentEnrollments = $activeEnrollments.Count
RosterStudents = $selectionRoster.enrolledCount
AccessUpdate = $true
ScopeChecks = $scopeChecks -join ', '
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
@@ -0,0 +1,746 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.CourseSelection;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/course-selections")]
public sealed class CourseSelectionsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string RoundManagers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
private const string OfferingManagers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string SelectionUsers =
OfferingManagers + "," +
SystemRoles.Student;
private const string RosterReaders =
OfferingManagers + "," +
SystemRoles.Teacher;
[HttpGet("rounds")]
[Authorize(Roles = SelectionUsers)]
public async Task<ActionResult> GetRounds(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = db.CourseSelectionRounds.AsNoTracking().AsQueryable();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (currentUserDataScope.Current.IsInRole(SystemRoles.Student))
source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft);
var now = DateTime.UtcNow;
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenByDescending(x => x.StartsAt)
.Select(x => new
{
x.Id,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.StartsAt,
x.EndsAt,
x.WithdrawalEndsAt,
x.MaxCredits,
x.Status,
IsAvailableNow =
x.Status == CourseSelectionRoundStatus.Open &&
now >= x.StartsAt &&
now <= x.EndsAt,
OfferingCount = x.Offerings.Count,
x.Notes,
x.UpdatedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("rounds")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> CreateRound(
CourseSelectionRoundRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateRoundAsync(request, cancellationToken);
if (validation is not null) return validation;
var round = new CourseSelectionRound
{
AcademicTermId = request.AcademicTermId,
Name = request.Name.Trim(),
StartsAt = request.StartsAt.ToUniversalTime(),
EndsAt = request.EndsAt.ToUniversalTime(),
WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(),
MaxCredits = request.MaxCredits,
Notes = Normalize(request.Notes)
};
db.CourseSelectionRounds.Add(round);
return await SaveAsync(round.Id, true, cancellationToken);
}
[HttpPut("rounds/{id:guid}")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> UpdateRound(
Guid id,
CourseSelectionRoundRequest request,
CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("只有草稿选课批次可以修改。");
var validation = await ValidateRoundAsync(request, cancellationToken);
if (validation is not null) return validation;
round.AcademicTermId = request.AcademicTermId;
round.Name = request.Name.Trim();
round.StartsAt = request.StartsAt.ToUniversalTime();
round.EndsAt = request.EndsAt.ToUniversalTime();
round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
round.MaxCredits = request.MaxCredits;
round.Notes = Normalize(request.Notes);
return await SaveAsync(id, false, cancellationToken);
}
[HttpDelete("rounds/{id:guid}")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> DeleteRound(Guid id, CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("只有草稿选课批次可以删除。");
db.CourseSelectionRounds.Remove(round);
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("rounds/{id:guid}/open")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> OpenRound(Guid id, CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds
.Include(x => x.Offerings)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("只有草稿选课批次可以开放。");
if (round.Offerings.Count == 0)
return ConflictProblem("至少配置一个可选教学班后才能开放。");
round.Status = CourseSelectionRoundStatus.Open;
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("rounds/{id:guid}/close")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> CloseRound(Guid id, CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Open)
return ConflictProblem("只有开放中的选课批次可以关闭。");
round.Status = CourseSelectionRoundStatus.Closed;
return await SaveAsync(id, false, cancellationToken);
}
[HttpGet("rounds/{roundId:guid}/offerings")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> GetOfferings(
Guid roundId,
CancellationToken cancellationToken)
{
var source = ScopedOfferings()
.AsNoTracking()
.Where(x => x.CourseSelectionRoundId == roundId);
return Ok(await source
.OrderBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.Select(x => new
{
x.Id,
x.CourseSelectionRoundId,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
x.TeachingTask.Course.Credits,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.Capacity,
EnrolledCount = x.Enrollments.Count(item =>
item.Status == CourseEnrollmentStatus.Enrolled),
x.IsOpenToAll,
x.Notes,
x.UpdatedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("rounds/{roundId:guid}/offerings")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> CreateOffering(
Guid roundId,
CourseSelectionOfferingRequest request,
CancellationToken cancellationToken)
{
var round = await db.CourseSelectionRounds.FindAsync([roundId], cancellationToken);
if (round is null) return NotFound();
if (round.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后不能调整教学班。");
var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken);
var validation = ValidateOffering(round, task, request);
if (validation is not null) return validation;
var offering = new CourseSelectionOffering
{
CourseSelectionRoundId = roundId,
TeachingTaskId = request.TeachingTaskId,
Capacity = request.Capacity,
IsOpenToAll = request.IsOpenToAll,
Notes = Normalize(request.Notes)
};
db.CourseSelectionOfferings.Add(offering);
return await SaveAsync(offering.Id, true, cancellationToken);
}
[HttpPut("rounds/{roundId:guid}/offerings/{id:guid}")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> UpdateOffering(
Guid roundId,
Guid id,
CourseSelectionOfferingRequest request,
CancellationToken cancellationToken)
{
var offering = await ScopedOfferings()
.Include(x => x.CourseSelectionRound)
.FirstOrDefaultAsync(
x => x.Id == id && x.CourseSelectionRoundId == roundId,
cancellationToken);
if (offering is null) return NotFound();
if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后不能调整教学班。");
var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken);
var validation = ValidateOffering(offering.CourseSelectionRound, task, request);
if (validation is not null) return validation;
offering.TeachingTaskId = request.TeachingTaskId;
offering.Capacity = request.Capacity;
offering.IsOpenToAll = request.IsOpenToAll;
offering.Notes = Normalize(request.Notes);
return await SaveAsync(id, false, cancellationToken);
}
[HttpDelete("rounds/{roundId:guid}/offerings/{id:guid}")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> DeleteOffering(
Guid roundId,
Guid id,
CancellationToken cancellationToken)
{
var offering = await ScopedOfferings()
.Include(x => x.CourseSelectionRound)
.FirstOrDefaultAsync(
x => x.Id == id && x.CourseSelectionRoundId == roundId,
cancellationToken);
if (offering is null) return NotFound();
if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后不能调整教学班。");
db.CourseSelectionOfferings.Remove(offering);
return await SaveAsync(id, false, cancellationToken);
}
[HttpGet("offerings/{id:guid}/roster")]
[Authorize(Roles = RosterReaders)]
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
{
var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseName = x.TeachingTask.Course!.Name,
CollegeId = x.TeachingTask.Course.CollegeId,
TeacherUserIds = x.TeachingTask.Teachers
.Select(item => item.Teacher!.UserId),
x.Capacity
})
.FirstOrDefaultAsync(cancellationToken);
if (offering is null) return NotFound();
var scope = currentUserDataScope.Current;
var isAssignedTeacher =
scope.IsInRole(SystemRoles.Teacher) &&
offering.TeacherUserIds.Contains(scope.UserId);
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
return Forbid();
var students = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
x.EnrolledAt
})
.ToListAsync(cancellationToken);
return Ok(new
{
offering.Id,
offering.TeachingTaskId,
offering.TaskNumber,
offering.TaskName,
offering.CourseName,
offering.Capacity,
EnrolledCount = students.Count,
Students = students
});
}
[HttpGet("student/options")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentOptions(
Guid roundId,
CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
var round = await db.CourseSelectionRounds.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken);
if (round is null || round.Status == CourseSelectionRoundStatus.Draft)
return NotFound();
var offerings = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x =>
x.CourseSelectionRoundId == roundId &&
x.TeachingTask!.Status == TeachingTaskStatus.Published &&
(x.IsOpenToAll ||
x.TeachingTask.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)))
.OrderBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new StudentOfferingDto(
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
x.TeachingTask.Course!.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.Course.Credits,
x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Capacity,
x.Enrollments.Count(item =>
item.Status == CourseEnrollmentStatus.Enrolled),
x.IsOpenToAll,
x.Enrollments
.Where(item => item.StudentId == student.Id)
.Select(item => (CourseEnrollmentStatus?)item.Status)
.FirstOrDefault(),
db.ScheduleEntries
.Where(entry =>
entry.TeachingTaskId == x.TeachingTaskId &&
entry.SchedulePlan!.AcademicTermId == round.AcademicTermId &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published)
.OrderBy(entry => entry.DayOfWeek)
.ThenBy(entry => entry.StartPeriod)
.Select(entry => new StudentScheduleDto(
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Classroom!.Name))
.ToList()))
.ToListAsync(cancellationToken);
return Ok(new
{
Round = new
{
round.Id,
round.Name,
round.StartsAt,
round.EndsAt,
round.WithdrawalEndsAt,
round.MaxCredits,
round.Status,
IsAvailableNow = CourseSelectionRules.IsSelectionOpen(
round,
DateTime.UtcNow)
},
Student = new
{
student.Id,
student.StudentNumber,
student.Name,
ClassName = student.AdministrativeClass!.Name
},
Offerings = offerings
});
}
[HttpGet("student/enrollments")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentEnrollments(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
var source = db.CourseEnrollments.AsNoTracking()
.Where(x => x.StudentId == student.Id);
if (academicTermId.HasValue)
{
source = source.Where(x =>
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
academicTermId);
}
return Ok(await source
.OrderByDescending(x => x.EnrolledAt)
.Select(x => new
{
x.Id,
x.CourseSelectionOfferingId,
RoundId = x.CourseSelectionOffering!.CourseSelectionRoundId,
RoundName = x.CourseSelectionOffering.CourseSelectionRound!.Name,
TermName = x.CourseSelectionOffering.CourseSelectionRound.AcademicTerm!.Name,
x.CourseSelectionOffering.TeachingTaskId,
TaskNumber = x.CourseSelectionOffering.TeachingTask!.TaskNumber,
CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code,
CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name,
x.CourseSelectionOffering.TeachingTask.Course.Credits,
TeacherNames = x.CourseSelectionOffering.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Status,
x.EnrolledAt,
x.WithdrawnAt,
CanWithdraw =
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering.CourseSelectionRound.Status ==
CourseSelectionRoundStatus.Open &&
DateTime.UtcNow <=
x.CourseSelectionOffering.CourseSelectionRound.WithdrawalEndsAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("student/enrollments")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Enroll(
StudentEnrollmentRequest request,
CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
if (student.Status != StudentStatus.Active)
return ConflictProblem("只有在籍学生可以选课。");
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
var now = DateTime.UtcNow;
if (!CourseSelectionRules.IsSelectionOpen(round, now))
return ConflictProblem("当前不在该选课批次的开放时间内。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
if (!offering.IsOpenToAll &&
!task.Classes.Any(x =>
x.AdministrativeClassId == student.AdministrativeClassId))
return Forbid();
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
return ConflictProblem("你已经选择了该教学班。");
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount >= offering.Capacity)
return ConflictProblem("该教学班名额已满。");
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId,
cancellationToken);
if (duplicateCourse)
return ConflictProblem("同一学期不能重复选择相同课程。");
var selectedCredits = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
.SumAsync(
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
cancellationToken) ?? 0;
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
{
return ConflictProblem(
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
.Distinct()
.ToArrayAsync(cancellationToken);
var selectedEntries = await PublishedScheduleEntries(
round.AcademicTermId,
selectedTaskIds,
cancellationToken);
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
if (existing is null)
{
existing = new CourseEnrollment
{
CourseSelectionOfferingId = offering.Id,
StudentId = student.Id
};
db.CourseEnrollments.Add(existing);
}
else
{
existing.Status = CourseEnrollmentStatus.Enrolled;
existing.EnrolledAt = now;
existing.WithdrawnAt = null;
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Created(string.Empty, new { existing.Id });
}
[HttpDelete("student/enrollments/{id:guid}")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Withdraw(Guid id, CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null) return ProfileNotFound();
var enrollment = await db.CourseEnrollments
.Include(x => x.CourseSelectionOffering)
.ThenInclude(x => x!.CourseSelectionRound)
.FirstOrDefaultAsync(
x => x.Id == id && x.StudentId == student.Id,
cancellationToken);
if (enrollment is null) return NotFound();
if (enrollment.Status != CourseEnrollmentStatus.Enrolled)
return ConflictProblem("该课程已经退选。");
if (!CourseSelectionRules.CanWithdraw(
enrollment.CourseSelectionOffering!.CourseSelectionRound!,
DateTime.UtcNow))
return ConflictProblem("当前批次已停止退课。");
enrollment.Status = CourseEnrollmentStatus.Withdrawn;
enrollment.WithdrawnAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken);
}
private IQueryable<CourseSelectionOffering> ScopedOfferings()
{
var source = db.CourseSelectionOfferings.AsQueryable();
var scope = currentUserDataScope.Current;
return scope.Scope == DataScope.All
? source
: source.Where(x =>
x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId);
}
private async Task<TeachingTask?> FindAccessibleTaskAsync(
Guid id,
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
var source = db.TeachingTasks.AsNoTracking().AsQueryable();
if (scope.Scope != DataScope.All)
{
source = source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
}
return await source
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
private ActionResult? ValidateOffering(
CourseSelectionRound round,
TeachingTask? task,
CourseSelectionOfferingRequest request)
{
if (task is null) return ValidationProblem("所选教学班不存在或超出数据范围。");
if (task.AcademicTermId != round.AcademicTermId)
return ValidationProblem("教学班与选课批次必须属于同一学期。");
if (task.Status != TeachingTaskStatus.Published)
return ValidationProblem("只有已发布的教学班可以进入选课。");
if (request.Capacity > task.Capacity)
return ValidationProblem($"选课容量不能超过教学班容量 {task.Capacity}。");
return null;
}
private async Task<ActionResult?> ValidateRoundAsync(
CourseSelectionRoundRequest request,
CancellationToken cancellationToken)
{
var startsAt = request.StartsAt.ToUniversalTime();
var endsAt = request.EndsAt.ToUniversalTime();
var withdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
if (startsAt >= endsAt)
return ValidationProblem("选课开始时间必须早于结束时间。");
if (withdrawalEndsAt < endsAt)
return ValidationProblem("退课截止时间不能早于选课结束时间。");
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
return null;
}
private async Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return await db.Students
.Include(x => x.AdministrativeClass)
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
}
private async Task<List<ScheduleEntry>> PublishedScheduleEntries(
Guid academicTermId,
IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken)
{
if (taskIds.Count == 0) return [];
return await db.ScheduleEntries.AsNoTracking()
.Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.SchedulePlan!.AcademicTermId == academicTermId &&
x.SchedulePlan.Status == SchedulePlanStatus.Published)
.ToListAsync(cancellationToken);
}
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("记录重复、容量已变化,或关联数据已失效。");
}
}
private ActionResult ProfileNotFound() =>
ConflictProblem("当前账号未关联有效学生档案,请联系教务管理员。");
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成选课操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record CourseSelectionRoundRequest(
Guid AcademicTermId,
[Required, MaxLength(120)] string Name,
DateTime StartsAt,
DateTime EndsAt,
DateTime WithdrawalEndsAt,
[Range(typeof(decimal), "0.5", "99")] decimal MaxCredits,
[MaxLength(500)] string? Notes);
public sealed record CourseSelectionOfferingRequest(
Guid TeachingTaskId,
[Range(1, 10000)] int Capacity,
bool IsOpenToAll,
[MaxLength(500)] string? Notes);
public sealed record StudentEnrollmentRequest(Guid OfferingId);
public sealed record StudentOfferingDto(
Guid Id,
Guid TeachingTaskId,
string TaskNumber,
string CourseCode,
string CourseName,
decimal Credits,
IEnumerable<string> TeacherNames,
int Capacity,
int EnrolledCount,
bool IsOpenToAll,
CourseEnrollmentStatus? EnrollmentStatus,
IEnumerable<StudentScheduleDto> Schedules);
public sealed record StudentScheduleDto(
int DayOfWeek,
int StartPeriod,
int PeriodCount,
int StartWeek,
int EndWeek,
WeekPattern WeekPattern,
string ClassroomName);
@@ -1,3 +1,4 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -35,6 +36,14 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
CourseSelectionRounds = await db.CourseSelectionRounds
.CountAsync(cancellationToken),
CourseSelectionOfferings = await db.CourseSelectionOfferings
.CountAsync(cancellationToken),
CourseEnrollments = await db.CourseEnrollments
.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,54 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class CourseSelectionRound : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public required string Name { get; set; }
public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; }
public DateTime WithdrawalEndsAt { get; set; }
public decimal MaxCredits { get; set; } = 30;
public CourseSelectionRoundStatus Status { get; set; } =
CourseSelectionRoundStatus.Draft;
public string? Notes { get; set; }
public ICollection<CourseSelectionOffering> Offerings { get; set; } = [];
}
public sealed class CourseSelectionOffering : EntityBase
{
public Guid CourseSelectionRoundId { get; set; }
public CourseSelectionRound? CourseSelectionRound { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public int Capacity { get; set; }
public bool IsOpenToAll { get; set; }
public string? Notes { get; set; }
public ICollection<CourseEnrollment> Enrollments { get; set; } = [];
}
public sealed class CourseEnrollment : EntityBase
{
public Guid CourseSelectionOfferingId { get; set; }
public CourseSelectionOffering? CourseSelectionOffering { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
public DateTime? WithdrawnAt { get; set; }
}
public enum CourseSelectionRoundStatus
{
Draft = 1,
Open = 2,
Closed = 3
}
public enum CourseEnrollmentStatus
{
Enrolled = 1,
Withdrawn = 2
}
@@ -0,0 +1,23 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Scheduling;
namespace Jiaowu.Api.Infrastructure.CourseSelection;
public static class CourseSelectionRules
{
public static bool IsSelectionOpen(CourseSelectionRound round, DateTime nowUtc) =>
round.Status == CourseSelectionRoundStatus.Open &&
nowUtc >= round.StartsAt &&
nowUtc <= round.EndsAt;
public static bool CanWithdraw(CourseSelectionRound round, DateTime nowUtc) =>
round.Status == CourseSelectionRoundStatus.Open &&
nowUtc <= round.WithdrawalEndsAt;
public static bool HasScheduleConflict(
IEnumerable<ScheduleEntry> candidateEntries,
IEnumerable<ScheduleEntry> selectedEntries) =>
candidateEntries.Any(candidate =>
selectedEntries.Any(selected =>
ScheduleConflictDetector.TimeOverlaps(candidate, selected)));
}
@@ -28,6 +28,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
Set<CourseSelectionOffering>();
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -267,6 +272,51 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSelectionRound>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.MaxCredits).HasPrecision(6, 1);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSelectionOffering>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
x.CourseSelectionRoundId,
x.TeachingTaskId
}).IsUnique();
entity.HasOne(x => x.CourseSelectionRound)
.WithMany(x => x.Offerings)
.HasForeignKey(x => x.CourseSelectionRoundId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseEnrollment>(entity =>
{
entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId })
.IsUnique();
entity.HasIndex(x => new { x.StudentId, x.Status });
entity.HasOne(x => x.CourseSelectionOffering)
.WithMany(x => x.Enrollments)
.HasForeignKey(x => x.CourseSelectionOfferingId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -421,6 +421,42 @@ public sealed class DatabaseInitializer(
}
await SeedDevelopmentUsersAsync(computerCollege.Id);
await SeedDevelopmentCourseSelectionAsync();
}
private async Task SeedDevelopmentCourseSelectionAsync()
{
if (await db.CourseSelectionRounds.AnyAsync())
{
return;
}
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var now = DateTime.UtcNow;
db.CourseSelectionRounds.Add(new CourseSelectionRound
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期第一轮选课",
StartsAt = now.AddDays(-2),
EndsAt = now.AddDays(14),
WithdrawalEndsAt = now.AddDays(21),
MaxCredits = 30,
Status = CourseSelectionRoundStatus.Open,
Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。",
Offerings =
[
new CourseSelectionOffering
{
TeachingTaskId = task.Id,
Capacity = 60,
IsOpenToAll = false,
Notes = "面向计科 2026-1 班开放。"
}
]
});
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
@@ -11,6 +11,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
private const string SchedulesMigration = "20260724_04_schedules";
private const string ClassCounselorMigration = "20260724_05_class_counselor";
private const string CourseSelectionMigration = "20260724_06_course_selection";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -58,6 +59,10 @@ public sealed class DevelopmentSqliteMigrator(
? ClassCounselorStatements.Skip(1)
: ClassCounselorStatements,
cancellationToken);
await ApplyMigrationAsync(
CourseSelectionMigration,
CourseSelectionStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -426,4 +431,79 @@ public sealed class DevelopmentSqliteMigrator(
ON "AdministrativeClasses" ("CounselorUserId");
"""
];
private static readonly string[] CourseSelectionStatements =
[
"""
CREATE TABLE IF NOT EXISTS "CourseSelectionRounds" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionRounds" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"WithdrawalEndsAt" TEXT NOT NULL,
"MaxCredits" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseSelectionRounds_AcademicTermId_Status"
ON "CourseSelectionRounds" ("AcademicTermId", "Status");
""",
"""
CREATE TABLE IF NOT EXISTS "CourseSelectionOfferings" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseSelectionOfferings" PRIMARY KEY,
"CourseSelectionRoundId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"Capacity" INTEGER NOT NULL,
"IsOpenToAll" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelectionRoundId"
FOREIGN KEY ("CourseSelectionRoundId") REFERENCES "CourseSelectionRounds" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTaskId"
ON "CourseSelectionOfferings" ("CourseSelectionRoundId", "TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseSelectionOfferings_TeachingTaskId"
ON "CourseSelectionOfferings" ("TeachingTaskId");
""",
"""
CREATE TABLE IF NOT EXISTS "CourseEnrollments" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseEnrollments" PRIMARY KEY,
"CourseSelectionOfferingId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"EnrolledAt" TEXT NOT NULL,
"WithdrawnAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOfferingId"
FOREIGN KEY ("CourseSelectionOfferingId") REFERENCES "CourseSelectionOfferings" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CourseEnrollments_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId"
ON "CourseEnrollments" ("CourseSelectionOfferingId", "StudentId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CourseEnrollments_StudentId_Status"
ON "CourseEnrollments" ("StudentId", "Status");
"""
];
}
@@ -0,0 +1,145 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseSelection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseSelectionRounds",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
WithdrawalEndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
MaxCredits = table.Column<decimal>(type: "decimal(6,1)", precision: 6, scale: 1, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseSelectionRounds", x => x.Id);
table.ForeignKey(
name: "FK_CourseSelectionRounds_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseSelectionOfferings",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseSelectionRoundId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Capacity = table.Column<int>(type: "int", nullable: false),
IsOpenToAll = table.Column<bool>(type: "tinyint(1)", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseSelectionOfferings", x => x.Id);
table.ForeignKey(
name: "FK_CourseSelectionOfferings_CourseSelectionRounds_CourseSelecti~",
column: x => x.CourseSelectionRoundId,
principalTable: "CourseSelectionRounds",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseSelectionOfferings_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseEnrollments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseSelectionOfferingId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
EnrolledAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
WithdrawnAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseEnrollments", x => x.Id);
table.ForeignKey(
name: "FK_CourseEnrollments_CourseSelectionOfferings_CourseSelectionOf~",
column: x => x.CourseSelectionOfferingId,
principalTable: "CourseSelectionOfferings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseEnrollments_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseEnrollments_CourseSelectionOfferingId_StudentId",
table: "CourseEnrollments",
columns: new[] { "CourseSelectionOfferingId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseEnrollments_StudentId_Status",
table: "CourseEnrollments",
columns: new[] { "StudentId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionOfferings_CourseSelectionRoundId_TeachingTask~",
table: "CourseSelectionOfferings",
columns: new[] { "CourseSelectionRoundId", "TeachingTaskId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionOfferings_TeachingTaskId",
table: "CourseSelectionOfferings",
column: "TeachingTaskId");
migrationBuilder.CreateIndex(
name: "IX_CourseSelectionRounds_AcademicTermId_Status",
table: "CourseSelectionRounds",
columns: new[] { "AcademicTermId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseEnrollments");
migrationBuilder.DropTable(
name: "CourseSelectionOfferings");
migrationBuilder.DropTable(
name: "CourseSelectionRounds");
}
}
}
@@ -382,6 +382,128 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CourseSelectionOfferingId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("WithdrawnAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CourseSelectionOfferingId", "StudentId")
.IsUnique();
b.HasIndex("StudentId", "Status");
b.ToTable("CourseEnrollments");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("Capacity")
.HasColumnType("int");
b.Property<Guid>("CourseSelectionRoundId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsOpenToAll")
.HasColumnType("tinyint(1)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("TeachingTaskId");
b.HasIndex("CourseSelectionRoundId", "TeachingTaskId")
.IsUnique();
b.ToTable("CourseSelectionOfferings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("MaxCredits")
.HasPrecision(6, 1)
.HasColumnType("decimal(6,1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("StartsAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("WithdrawalEndsAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Status");
b.ToTable("CourseSelectionRounds");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.Property<Guid>("Id")
@@ -1218,6 +1340,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering")
.WithMany("Enrollments")
.HasForeignKey("CourseSelectionOfferingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CourseSelectionOffering");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
.WithMany("Offerings")
.HasForeignKey("CourseSelectionRoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CourseSelectionRound");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
@@ -1453,6 +1624,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Students");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Navigation("Enrollments");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b =>
{
b.Navigation("Offerings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b =>
{
b.Navigation("Courses");
@@ -0,0 +1,82 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.CourseSelection;
namespace Jiaowu.Api.Tests;
public sealed class CourseSelectionRulesTests
{
[Fact]
public void Selection_requires_open_status_and_active_window()
{
var now = new DateTime(2026, 7, 24, 8, 0, 0, DateTimeKind.Utc);
var round = CreateRound(now.AddHours(-1), now.AddHours(1));
Assert.True(CourseSelectionRules.IsSelectionOpen(round, now));
round.Status = CourseSelectionRoundStatus.Closed;
Assert.False(CourseSelectionRules.IsSelectionOpen(round, now));
round.Status = CourseSelectionRoundStatus.Open;
Assert.False(CourseSelectionRules.IsSelectionOpen(round, now.AddHours(2)));
}
[Fact]
public void Withdrawal_respects_deadline()
{
var now = new DateTime(2026, 7, 24, 8, 0, 0, DateTimeKind.Utc);
var round = CreateRound(now.AddDays(-1), now.AddDays(1));
round.WithdrawalEndsAt = now.AddMinutes(30);
Assert.True(CourseSelectionRules.CanWithdraw(round, now));
Assert.False(CourseSelectionRules.CanWithdraw(round, now.AddHours(1)));
}
[Fact]
public void Schedule_conflict_detects_overlapping_weeks_and_periods()
{
var selected = CreateEntry(1, 1, 2, 1, 16, WeekPattern.All);
var overlapping = CreateEntry(1, 2, 2, 1, 16, WeekPattern.All);
Assert.True(CourseSelectionRules.HasScheduleConflict(
[overlapping],
[selected]));
}
[Fact]
public void Odd_and_even_week_entries_do_not_conflict()
{
var selected = CreateEntry(3, 5, 2, 1, 16, WeekPattern.Odd);
var candidate = CreateEntry(3, 5, 2, 1, 16, WeekPattern.Even);
Assert.False(CourseSelectionRules.HasScheduleConflict(
[candidate],
[selected]));
}
private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) =>
new()
{
Name = "第一轮选课",
StartsAt = startsAt,
EndsAt = endsAt,
WithdrawalEndsAt = endsAt.AddDays(1),
Status = CourseSelectionRoundStatus.Open
};
private static ScheduleEntry CreateEntry(
int dayOfWeek,
int startPeriod,
int periodCount,
int startWeek,
int endWeek,
WeekPattern weekPattern) =>
new()
{
DayOfWeek = dayOfWeek,
StartPeriod = startPeriod,
PeriodCount = periodCount,
StartWeek = startWeek,
EndWeek = endWeek,
WeekPattern = weekPattern
};
}
+1
View File
@@ -16,6 +16,7 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
+25 -3
View File
@@ -9,6 +9,7 @@ import {
Reading,
Tickets,
Calendar,
CircleCheck,
User,
UserFilled,
} from '@element-plus/icons-vue'
@@ -19,6 +20,16 @@ const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const mobileMenu = ref(false)
const workspaceLabel = computed(() => {
const roles = auth.user?.roles ?? []
if (roles.includes('SuperAdmin')) return '系统全域管理'
if (roles.includes('AcademicAdmin')) return '校级教务管理'
if (roles.includes('CollegeAdmin')) return '学院教务管理'
if (roles.includes('Counselor')) return '辅导员班级工作'
if (roles.includes('Teacher')) return '教师教学工作'
if (roles.includes('Student')) return '学生学业服务'
return '教务工作台'
})
const pageTitle = computed(() => {
const titles: Record<string, string> = {
@@ -29,6 +40,7 @@ const pageTitle = computed(() => {
curriculum: '培养方案',
'teaching-tasks': '教学任务',
schedules: '排课与课表',
'course-selections': '选课与教学班',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
@@ -57,7 +69,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
<div v-if="!collapsed" class="term-stamp">
<span>当前工作区</span>
<b>校级教务管理</b>
<b>{{ workspaceLabel }}</b>
</div>
<el-menu
@@ -71,7 +83,10 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><DataAnalysis /></el-icon>
<template #title>教务总览</template>
</el-menu-item>
<el-menu-item index="/base-data">
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role))"
index="/base-data"
>
<el-icon><OfficeBuilding /></el-icon>
<template #title>基础数据</template>
</el-menu-item>
@@ -107,6 +122,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><Calendar /></el-icon>
<template #title>排课与课表</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'].includes(role))"
index="/course-selections"
>
<el-icon><CircleCheck /></el-icon>
<template #title>{{ auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '选课管理' : '学生选课' }}</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
@@ -115,7 +137,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 核心可用版</span>
<p>主数据培养方案与教学运行已就绪</p>
<p>教学运行排课与学生选课已就绪</p>
</div>
</aside>
+18
View File
@@ -25,6 +25,7 @@ const router = createRouter({
path: 'base-data',
name: 'base-data',
component: () => import('../views/BaseDataView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
},
{
path: 'personnel',
@@ -57,6 +58,14 @@ const router = createRouter({
component: () => import('../views/SchedulesView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
},
{
path: 'course-selections',
name: 'course-selections',
component: () => import('../views/CourseSelectionView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
},
},
{
path: 'users',
name: 'users',
@@ -75,6 +84,15 @@ router.beforeEach((to) => {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' }
if (
to.name === 'dashboard' &&
auth.user?.roles.includes('Student') &&
!auth.user.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
)
) {
return { name: 'course-selections' }
}
const roles = to.meta.roles as string[] | undefined
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
return { name: 'dashboard' }
+151
View File
@@ -249,6 +249,135 @@ button { cursor: pointer; }
.schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; }
.schedule-card > button:hover { color: white; }
.selection-page { min-width: 0; }
.selection-round-strip {
min-height: 104px; padding: 10px; display: flex; gap: 9px; overflow-x: auto;
border: 1px solid var(--line); background: white;
}
.selection-round-strip > button {
flex: 0 0 285px; min-height: 82px; padding: 13px 15px; display: grid;
grid-template-columns: 1fr auto; gap: 5px 12px; text-align: left;
border: 1px solid var(--line); border-left: 3px solid #bdc3ce;
color: var(--ink); background: #fafbfc;
}
.selection-round-strip > button:hover { background: white; }
.selection-round-strip > button.active {
border-color: var(--indigo); border-left-color: #48c8b7;
color: white; background: linear-gradient(115deg, #1b3067, #294584);
}
.selection-round-strip span, .selection-round-strip small {
overflow: hidden; color: #868f9e; font-size: 9px; white-space: nowrap; text-overflow: ellipsis;
}
.selection-round-strip b {
grid-column: 1 / -1; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif;
font-size: 14px; white-space: nowrap; text-overflow: ellipsis;
}
.selection-round-strip i {
align-self: start; padding: 3px 7px; border-radius: 10px;
color: #6d7583; background: #edf0f3; font-size: 9px; font-style: normal;
}
.selection-round-strip i.open { color: var(--teal); background: #e5f4f1; }
.selection-round-strip > button.active span,
.selection-round-strip > button.active small { color: #c6d0e8; }
.selection-round-strip > button.active i { color: white; background: rgba(255,255,255,.14); }
.selection-window {
min-height: 142px; display: grid; grid-template-columns: 105px minmax(300px, 1fr) auto;
align-items: stretch; color: white;
background:
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px),
linear-gradient(112deg, #162858, #243d7d 70%, #176b70);
background-size: 28px 28px, 28px 28px, auto;
overflow: hidden;
}
.window-seal {
padding: 20px 12px; display: grid; place-content: center; justify-items: center; gap: 9px;
border-right: 1px solid rgba(255,255,255,.13); color: #57d4c2;
}
.window-seal .el-icon { font-size: 26px; }
.window-seal span { font-size: 10px; font-weight: 700; letter-spacing: .08em; }
.window-copy { padding: 24px 27px; align-self: center; min-width: 0; }
.window-copy > span { color: #5ed4c4; font: 700 9px/1 Consolas, monospace; letter-spacing: .15em; }
.window-copy h3 {
margin: 9px 0 8px; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif;
font-size: 22px; font-weight: 500; letter-spacing: .03em; white-space: nowrap; text-overflow: ellipsis;
}
.window-copy p { margin: 0; color: #c4cee8; font-size: 10px; }
.window-copy em {
margin-left: 13px; padding-left: 13px; border-left: 1px solid rgba(255,255,255,.2);
color: #edcf9a; font-style: normal;
}
.credit-meter {
width: 310px; padding: 22px 28px; align-self: center;
border-left: 1px solid rgba(255,255,255,.13);
}
.credit-meter > div:first-child { display: flex; align-items: baseline; gap: 7px; }
.credit-meter span, .credit-meter small { color: #c2cce5; font-size: 10px; }
.credit-meter b { margin-left: auto; color: #5bd4c3; font: 700 34px/1 Consolas, monospace; }
.credit-track { height: 5px; margin-top: 12px; overflow: hidden; background: rgba(255,255,255,.15); }
.credit-track i { height: 100%; display: block; background: linear-gradient(90deg, #49c9b7, #e3ad54); }
.credit-meter p { margin: 9px 0 0; color: #aeb9d8; font-size: 9px; }
.round-actions {
width: 285px; padding: 23px; display: flex; flex-wrap: wrap; align-content: center;
justify-content: flex-end; gap: 8px; border-left: 1px solid rgba(255,255,255,.13);
}
.round-actions .el-button + .el-button { margin-left: 0; }
.selection-ledger-head {
min-height: 95px; padding: 18px 21px; display: flex; align-items: center;
justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line);
}
.selection-ledger-head span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .14em; }
.selection-ledger-head h3 { margin: 7px 0 4px; font-family: "STZhongsong", "Songti SC", serif; font-size: 19px; }
.selection-ledger-head p { margin: 0; color: var(--muted); font-size: 10px; }
.capacity-number { color: var(--indigo); font: 700 13px/1 Consolas, monospace; }
.offering-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
.offering-ticket {
min-width: 0; min-height: 250px; display: flex; flex-direction: column;
border: 1px solid var(--line); background: white; box-shadow: 0 6px 18px rgba(24,36,68,.045);
}
.offering-ticket > header {
min-height: 99px; padding: 19px 20px; display: flex; justify-content: space-between;
gap: 16px; border-bottom: 1px dashed #d7dce4; position: relative;
}
.offering-ticket > header::before,
.offering-ticket > header::after {
content: ""; position: absolute; bottom: -7px; width: 12px; height: 12px;
border-radius: 50%; background: var(--soft);
}
.offering-ticket > header::before { left: -7px; }
.offering-ticket > header::after { right: -7px; }
.offering-ticket header span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .08em; }
.offering-ticket h3 {
margin: 8px 0 6px; font-family: "STZhongsong", "Songti SC", serif;
font-size: 20px; font-weight: 600;
}
.offering-ticket header p { margin: 0; color: var(--muted); font-size: 11px; }
.selected-mark {
flex: 0 0 auto; height: fit-content; padding: 5px 9px; display: flex; align-items: center; gap: 4px;
border-radius: 13px; color: var(--teal); background: #e8f5f2; font-size: 10px; font-style: normal;
}
.ticket-schedules { min-height: 85px; padding: 14px 20px; display: grid; align-content: center; gap: 8px; }
.ticket-schedules > div { display: flex; align-items: flex-start; gap: 7px; color: #4e596d; font-size: 10px; line-height: 1.5; }
.ticket-schedules .el-icon { flex: 0 0 auto; margin-top: 1px; color: var(--indigo); }
.schedule-missing { color: #a36d22; font-size: 10px; }
.offering-ticket > footer {
margin-top: auto; min-height: 65px; padding: 13px 20px; display: flex; align-items: center;
gap: 18px; background: #fafbfc; border-top: 1px solid #edf0f4;
}
.seat-meter { flex: 1; min-width: 0; }
.seat-meter > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 9px; }
.seat-meter > div { height: 4px; overflow: hidden; background: #e2e6ec; }
.seat-meter i { height: 100%; display: block; background: var(--teal); }
.roster-summary {
margin: 0 0 18px; padding: 16px; display: flex; align-items: center; gap: 14px;
color: white; background: linear-gradient(115deg, #1b3067, #294584);
}
.roster-summary > .el-icon { font-size: 28px; color: #5bd4c3; }
.roster-summary span, .roster-summary b, .roster-summary small { display: block; }
.roster-summary span { color: #59d2c1; font: 700 9px Consolas, monospace; }
.roster-summary b { margin-top: 5px; font-size: 14px; }
.roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
@@ -339,6 +468,13 @@ button { cursor: pointer; }
.schedule-search { flex-wrap: wrap; }
.schedule-search .el-input { width: 100%; }
.schedule-search > span { width: 100%; margin-left: 0; }
.selection-window { grid-template-columns: 82px 1fr; }
.credit-meter, .round-actions {
grid-column: 1 / -1; width: auto; border-top: 1px solid rgba(255,255,255,.13);
border-left: none;
}
.round-actions { justify-content: flex-start; }
.offering-grid { grid-template-columns: 1fr; }
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; }
@@ -350,6 +486,21 @@ button { cursor: pointer; }
.login-panel { padding: 32px 22px; background: white; }
}
@media (max-width: 600px) {
.selection-round-strip > button { flex-basis: 240px; }
.selection-window { display: block; }
.window-seal { min-height: 54px; padding: 10px; display: flex; border-right: none; border-bottom: 1px solid rgba(255,255,255,.13); }
.window-seal .el-icon { font-size: 18px; }
.window-copy { padding: 20px 18px; }
.window-copy h3 { white-space: normal; }
.window-copy em { display: block; margin: 7px 0 0; padding: 0; border: none; }
.credit-meter, .round-actions { padding: 18px; }
.selection-ledger-head { align-items: flex-start; flex-direction: column; }
.offering-ticket > footer { align-items: stretch; flex-direction: column; }
.offering-ticket > footer .el-button { width: 100%; }
.el-drawer { width: 100% !important; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}
+628
View File
@@ -0,0 +1,628 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
CircleCheck,
Clock,
Plus,
Refresh,
Tickets,
UserFilled,
} from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const managerRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']
const isManager = computed(() =>
auth.user?.roles.some((role) => managerRoles.includes(role)) ?? false,
)
const canManageRounds = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
)
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
const terms = ref<any[]>([])
const rounds = ref<any[]>([])
const selectedRound = ref<any | null>(null)
const offerings = ref<any[]>([])
const tasks = ref<any[]>([])
const enrollments = ref<any[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const roundDialog = ref(false)
const offeringDialog = ref(false)
const rosterDrawer = ref(false)
const editingRoundId = ref('')
const editingOfferingId = ref('')
const roster = ref<any | null>(null)
const roundForm = reactive<Record<string, any>>({})
const offeringForm = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿',
Open: '开放中',
Closed: '已关闭',
}
const patternLabels: Record<string, string> = {
All: '每周',
Odd: '单周',
Even: '双周',
}
const weekdayLabels = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const selectedCredits = computed(() =>
offerings.value
.filter((item) => item.enrollmentStatus === 'Enrolled')
.reduce((sum, item) => sum + Number(item.credits), 0),
)
const creditPercent = computed(() => {
const maximum = Number(selectedRound.value?.maxCredits || 1)
return Math.min(100, Math.round((selectedCredits.value / maximum) * 100))
})
const selectedCount = computed(() =>
offerings.value.filter((item) => item.enrollmentStatus === 'Enrolled').length,
)
function formatDateTime(value: string) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(value))
}
function toPickerValue(value: string) {
if (!value) return ''
const date = new Date(value)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:00`
}
function toIso(value: string) {
return new Date(value.replace(' ', 'T')).toISOString()
}
function formatSchedule(schedule: any) {
const periods = schedule.periodCount === 1
? `${schedule.startPeriod}`
: `${schedule.startPeriod}${schedule.startPeriod + schedule.periodCount - 1}`
return `${weekdayLabels[schedule.dayOfWeek]} ${periods} · ${schedule.startWeek}${schedule.endWeek}${patternLabels[schedule.weekPattern] === '每周' ? '' : ` · ${patternLabels[schedule.weekPattern]}`} · ${schedule.classroomName}`
}
async function loadRounds(keepSelection = true) {
loading.value = true
try {
rounds.value = (await http.get('/course-selections/rounds')).data
const previousId = keepSelection ? selectedRound.value?.id : undefined
const preferred = rounds.value.find((item) => item.id === previousId)
?? rounds.value.find((item) => item.isAvailableNow)
?? rounds.value[0]
if (preferred) await selectRound(preferred)
else {
selectedRound.value = null
offerings.value = []
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectRound(round: any) {
selectedRound.value = round
detailLoading.value = true
try {
if (isStudent.value) {
const [optionResponse, enrollmentResponse] = await Promise.all([
http.get('/course-selections/student/options', { params: { roundId: round.id } }),
http.get('/course-selections/student/enrollments', {
params: { academicTermId: round.academicTermId },
}),
])
offerings.value = optionResponse.data.offerings
enrollments.value = enrollmentResponse.data
} else {
offerings.value = (
await http.get(`/course-selections/rounds/${round.id}/offerings`)
).data
if (round.status === 'Draft') await loadTasks(round.academicTermId)
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
async function loadTasks(academicTermId: string) {
const { data } = await http.get('/teaching-tasks', {
params: {
academicTermId,
status: 'Published',
page: 1,
pageSize: 100,
},
})
tasks.value = data.items
}
function openRound(round?: any) {
editingRoundId.value = round?.id ?? ''
const currentTerm = terms.value.find((item) => item.isCurrent)
const now = new Date()
const ends = new Date(now.getTime() + 7 * 86400000)
const withdrawal = new Date(now.getTime() + 14 * 86400000)
Object.assign(roundForm, {
academicTermId: round?.academicTermId ?? currentTerm?.id,
name: round?.name ?? '',
startsAt: round ? toPickerValue(round.startsAt) : toPickerValue(now.toISOString()),
endsAt: round ? toPickerValue(round.endsAt) : toPickerValue(ends.toISOString()),
withdrawalEndsAt: round
? toPickerValue(round.withdrawalEndsAt)
: toPickerValue(withdrawal.toISOString()),
maxCredits: round?.maxCredits ?? 30,
notes: round?.notes ?? '',
})
roundDialog.value = true
}
async function saveRound() {
if (!roundForm.academicTermId || !roundForm.name?.trim()) {
ElMessage.warning('请选择学期并填写批次名称。')
return
}
try {
const payload = {
...roundForm,
startsAt: toIso(roundForm.startsAt),
endsAt: toIso(roundForm.endsAt),
withdrawalEndsAt: toIso(roundForm.withdrawalEndsAt),
}
if (editingRoundId.value) {
await http.put(`/course-selections/rounds/${editingRoundId.value}`, payload)
} else {
await http.post('/course-selections/rounds', payload)
}
roundDialog.value = false
ElMessage.success(editingRoundId.value ? '选课批次已更新' : '选课批次草稿已创建')
await loadRounds(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteRound(round: any) {
try {
await ElMessageBox.confirm(`删除选课批次“${round.name}”?`, '删除草稿', {
type: 'warning',
})
await http.delete(`/course-selections/rounds/${round.id}`)
await loadRounds(false)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function openSelection(round: any) {
try {
await ElMessageBox.confirm(
'开放后批次及教学班配置将锁定,学生可在设置的时间窗口内选课。',
'开放选课',
{ type: 'warning', confirmButtonText: '确认开放', cancelButtonText: '取消' },
)
await http.post(`/course-selections/rounds/${round.id}/open`)
ElMessage.success('选课批次已开放')
await loadRounds()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function closeSelection(round: any) {
try {
await ElMessageBox.confirm(
'关闭后学生将不能继续选课或退课,现有教学班名单会保留。',
'关闭选课',
{ type: 'warning', confirmButtonText: '确认关闭', cancelButtonText: '取消' },
)
await http.post(`/course-selections/rounds/${round.id}/close`)
await loadRounds()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openOffering(offering?: any) {
editingOfferingId.value = offering?.id ?? ''
Object.assign(offeringForm, {
teachingTaskId: offering?.teachingTaskId,
capacity: offering?.capacity ?? 60,
isOpenToAll: offering?.isOpenToAll ?? false,
notes: offering?.notes ?? '',
})
offeringDialog.value = true
}
function onTaskChanged(taskId: string) {
const task = tasks.value.find((item) => item.id === taskId)
if (task) offeringForm.capacity = task.capacity
}
async function saveOffering() {
if (!offeringForm.teachingTaskId) {
ElMessage.warning('请选择要进入选课的教学班。')
return
}
try {
const base = `/course-selections/rounds/${selectedRound.value.id}/offerings`
if (editingOfferingId.value) {
await http.put(`${base}/${editingOfferingId.value}`, offeringForm)
} else {
await http.post(base, offeringForm)
}
offeringDialog.value = false
ElMessage.success(editingOfferingId.value ? '教学班配置已更新' : '教学班已加入本轮选课')
await selectRound(selectedRound.value)
await loadRounds()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteOffering(offering: any) {
try {
await ElMessageBox.confirm(`从本轮移除“${offering.taskName}”?`, '移除教学班', {
type: 'warning',
})
await http.delete(
`/course-selections/rounds/${selectedRound.value.id}/offerings/${offering.id}`,
)
await selectRound(selectedRound.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function showRoster(offering: any) {
try {
roster.value = (
await http.get(`/course-selections/offerings/${offering.id}/roster`)
).data
rosterDrawer.value = true
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function enroll(offering: any) {
try {
await http.post('/course-selections/student/enrollments', {
offeringId: offering.id,
})
ElMessage.success(`已选“${offering.courseName}`)
await selectRound(selectedRound.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function withdraw(offering: any) {
const enrollment = enrollments.value.find(
(item) =>
item.courseSelectionOfferingId === offering.id &&
item.status === 'Enrolled',
)
if (!enrollment) return
try {
await ElMessageBox.confirm(
`确定退选“${offering.courseName}”吗?名额释放后可能被其他同学选择。`,
'确认退课',
{ type: 'warning', confirmButtonText: '确认退选', cancelButtonText: '暂不退选' },
)
await http.delete(`/course-selections/student/enrollments/${enrollment.id}`)
ElMessage.success('已退选')
await selectRound(selectedRound.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
try {
if (isManager.value) terms.value = (await http.get('/base-data/terms')).data
await loadRounds(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
})
</script>
<template>
<div class="page-stack selection-page">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE REGISTRATION</span>
<h2>{{ isStudent ? '学生选课' : '选课管理' }}</h2>
<p v-if="isStudent">在开放时间内安排本学期课程系统会实时校验容量学分与上课时间</p>
<p v-else>设置选课窗口投放教学班并以实时名单掌握教学班容量</p>
</div>
<el-button v-if="canManageRounds" type="primary" :icon="Plus" @click="openRound()">
新建选课批次
</el-button>
<el-button v-else :icon="Refresh" @click="loadRounds()">刷新名额</el-button>
</section>
<section v-if="rounds.length" class="selection-round-strip" v-loading="loading">
<button
v-for="round in rounds"
:key="round.id"
type="button"
:class="{ active: selectedRound?.id === round.id }"
@click="selectRound(round)"
>
<span>{{ round.termName }}</span>
<b>{{ round.name }}</b>
<small>{{ formatDateTime(round.startsAt) }} {{ formatDateTime(round.endsAt) }}</small>
<i :class="round.status.toLowerCase()">{{ statusLabels[round.status] }}</i>
</button>
</section>
<template v-if="selectedRound">
<section class="selection-window">
<div class="window-seal">
<el-icon><Clock /></el-icon>
<span>{{ selectedRound.isAvailableNow ? '正在开放' : statusLabels[selectedRound.status] }}</span>
</div>
<div class="window-copy">
<span>SELECTION WINDOW · {{ selectedRound.termName }}</span>
<h3>{{ selectedRound.name }}</h3>
<p>
选课 {{ formatDateTime(selectedRound.startsAt) }}{{ formatDateTime(selectedRound.endsAt) }}
<em>退课截止 {{ formatDateTime(selectedRound.withdrawalEndsAt) }}</em>
</p>
</div>
<div v-if="isStudent" class="credit-meter">
<div>
<span>已选学分</span>
<b>{{ selectedCredits }}</b>
<small>/ {{ selectedRound.maxCredits }}</small>
</div>
<div class="credit-track"><i :style="{ width: `${creditPercent}%` }" /></div>
<p>{{ selectedCount }} 门课程 · 剩余可选 {{ Math.max(0, selectedRound.maxCredits - selectedCredits) }} 学分</p>
</div>
<div v-else class="round-actions">
<el-button
v-if="canManageRounds && selectedRound.status === 'Draft'"
@click="openRound(selectedRound)"
>编辑批次</el-button>
<el-button
v-if="canManageRounds && selectedRound.status === 'Draft'"
type="success"
@click="openSelection(selectedRound)"
>开放选课</el-button>
<el-button
v-if="canManageRounds && selectedRound.status === 'Open'"
type="warning"
@click="closeSelection(selectedRound)"
>关闭选课</el-button>
<el-button
v-if="canManageRounds && selectedRound.status === 'Draft'"
type="danger"
plain
@click="deleteRound(selectedRound)"
>删除草稿</el-button>
</div>
</section>
<section v-if="isManager" class="data-card" v-loading="detailLoading">
<div class="selection-ledger-head">
<div>
<span>OFFERING LEDGER</span>
<h3>本轮教学班</h3>
<p>{{ offerings.length }} 个教学班 · 开放后配置锁定名单随学生选退实时更新</p>
</div>
<el-button
v-if="selectedRound.status === 'Draft'"
type="primary"
:icon="Plus"
@click="openOffering()"
>加入教学班</el-button>
</div>
<el-table :data="offerings" class="data-table">
<el-table-column label="教学班 / 课程" min-width="260">
<template #default="{ row }">
<div class="course-name">
<b>{{ row.taskName }}</b>
<span>{{ row.taskNumber }} · {{ row.courseCode }} {{ row.courseName }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="教师 / 行政班" min-width="200">
<template #default="{ row }">
<div class="course-name">
<b>{{ row.teacherNames.join('、') || '未安排' }}</b>
<span>{{ row.isOpenToAll ? '全校开放' : row.classNames.join('、') }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="学分" width="75" prop="credits" />
<el-table-column label="名单 / 容量" width="125">
<template #default="{ row }">
<b class="capacity-number">{{ row.enrolledCount }} / {{ row.capacity }}</b>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="showRoster(row)">查看名单</el-button>
<el-button
v-if="selectedRound.status === 'Draft'"
link
type="primary"
@click="openOffering(row)"
>编辑</el-button>
<el-button
v-if="selectedRound.status === 'Draft'"
link
type="danger"
@click="deleteOffering(row)"
>移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="本轮尚未加入教学班" /></template>
</el-table>
</section>
<section v-else class="offering-grid" v-loading="detailLoading">
<article v-for="offering in offerings" :key="offering.id" class="offering-ticket">
<header>
<div>
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
<h3>{{ offering.courseName }}</h3>
<p>{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分</p>
</div>
<i v-if="offering.enrollmentStatus === 'Enrolled'" class="selected-mark">
<el-icon><CircleCheck /></el-icon> 已选
</i>
</header>
<div class="ticket-schedules">
<div v-for="schedule in offering.schedules" :key="formatSchedule(schedule)">
<el-icon><Tickets /></el-icon>
<span>{{ formatSchedule(schedule) }}</span>
</div>
<span v-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
</div>
<footer>
<div class="seat-meter">
<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>
<el-button
v-if="offering.enrollmentStatus === 'Enrolled'"
type="danger"
plain
:disabled="selectedRound.status !== 'Open'"
@click="withdraw(offering)"
>退选</el-button>
<el-button
v-else
type="primary"
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || !offering.schedules.length"
@click="enroll(offering)"
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
</footer>
</article>
<el-empty v-if="!offerings.length" description="本轮没有适合你所在班级的课程" />
</section>
</template>
<el-empty v-else-if="!loading" description="暂无选课批次" />
<el-dialog
v-model="roundDialog"
:title="editingRoundId ? '编辑选课批次' : '新建选课批次'"
width="720px"
>
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="开课学期" required>
<el-select v-model="roundForm.academicTermId">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
</el-select>
</el-form-item>
<el-form-item label="批次名称" required>
<el-input v-model="roundForm.name" placeholder="如 第一轮选课" />
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="选课开始时间" required>
<el-date-picker v-model="roundForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
<el-form-item label="选课结束时间" required>
<el-date-picker v-model="roundForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="退课截止时间" required>
<el-date-picker v-model="roundForm.withdrawalEndsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
<el-form-item label="本轮学分上限">
<el-input-number v-model="roundForm.maxCredits" :min="0.5" :max="99" :step="0.5" />
</el-form-item>
</div>
<el-form-item label="说明">
<el-input v-model="roundForm.notes" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="roundDialog = false">取消</el-button>
<el-button type="primary" @click="saveRound">保存草稿</el-button>
</template>
</el-dialog>
<el-dialog
v-model="offeringDialog"
:title="editingOfferingId ? '编辑教学班配置' : '加入教学班'"
width="620px"
>
<el-form label-position="top">
<el-form-item label="已发布教学班" required>
<el-select
v-model="offeringForm.teachingTaskId"
filterable
@change="onTaskChanged"
>
<el-option
v-for="task in tasks"
:key="task.id"
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}`"
:value="task.id"
/>
</el-select>
</el-form-item>
<div class="form-grid compact">
<el-form-item label="选课容量">
<el-input-number v-model="offeringForm.capacity" :min="1" />
</el-form-item>
<el-form-item label="选课对象">
<el-switch
v-model="offeringForm.isOpenToAll"
active-text="全校学生"
inactive-text="教学任务关联班级"
/>
</el-form-item>
</div>
<el-form-item label="说明">
<el-input v-model="offeringForm.notes" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="offeringDialog = false">取消</el-button>
<el-button type="primary" @click="saveOffering">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="rosterDrawer" title="教学班名单" size="620px">
<template v-if="roster">
<div class="roster-summary">
<el-icon><UserFilled /></el-icon>
<div>
<span>{{ roster.taskNumber }}</span>
<b>{{ roster.taskName }}</b>
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} </small>
</div>
</div>
<el-table :data="roster.students">
<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 label="选课时间" min-width="130">
<template #default="{ row }">{{ formatDateTime(row.enrolledAt) }}</template>
</el-table-column>
<template #empty><el-empty description="暂无学生选课" /></template>
</el-table>
</template>
</el-drawer>
</div>
</template>
+14 -2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue'
import http from '../api/http'
import { useAuthStore } from '../stores/auth'
interface DashboardData {
currentTerm?: { name: string; startDate: string; endDate: string }
@@ -10,6 +11,7 @@ interface DashboardData {
}
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const data = ref<DashboardData>({ counts: {} })
@@ -40,7 +42,11 @@ onMounted(async () => {
</p>
<p v-else>请先在基础数据中建立学期档案</p>
</div>
<button type="button" @click="router.push('/base-data')">
<button
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role))"
type="button"
@click="router.push('/base-data')"
>
<span>维护学期与组织数据</span>
<b></b>
</button>
@@ -101,18 +107,24 @@ onMounted(async () => {
<b>{{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布</b>
<i class="done">已建立</i>
</div>
<div>
<span>学生选课</span>
<b>{{ data.counts.courseSelectionRounds ?? 0 }} 个批次 · {{ data.counts.courseEnrollments ?? 0 }} 条有效选课</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>排课与课表发布链路已就绪下一步进入学生选课轮次容量与候补管理</p>
<p>选课批次容量校验和教学班名单已就绪下一步进入成绩录入审核与学业档案</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
<span class="active">培养方案</span>
<span class="active">排课课表</span>
<span class="active">学生选课</span>
</div>
</article>
</section>