选课批次:时间窗口、退课截止、学分上限、开放/关闭。
教学班投放:容量、班级范围、全校开放。 学生选退课:容量、重复课程、学分上限、课表冲突校验。 教学班实时名单。 校级、学院级、学生角色分级操作。 SQLite 本地增量升级及演示数据。 MySQL 正式 EF Core 迁移。 Vue 已编译进 wwwroot,单服务运行无需 npm run dev。 学生端和管理端均完成桌面、手机响应式检查。
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单。
|
||||||
|
|
||||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,24 @@ try {
|
|||||||
-Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" `
|
-Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" `
|
||||||
-Headers $headers
|
-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
|
$managedUsers = Invoke-RestMethod -Uri 'http://localhost:5255/api/users' -Headers $headers
|
||||||
$teacherAccount = @($managedUsers) |
|
$teacherAccount = @($managedUsers) |
|
||||||
Where-Object { $_.userName -eq 'teacher' } |
|
Where-Object { $_.userName -eq 'teacher' } |
|
||||||
@@ -162,6 +180,48 @@ try {
|
|||||||
"$($scenario.UserName):$($scenario.Scope)"
|
"$($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
|
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
|
||||||
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
||||||
$unknownApiParameters = @{
|
$unknownApiParameters = @{
|
||||||
@@ -186,6 +246,10 @@ try {
|
|||||||
TeachingTasks = $teachingTasks.total
|
TeachingTasks = $teachingTasks.total
|
||||||
Schedules = @($schedulePlans).Count
|
Schedules = @($schedulePlans).Count
|
||||||
ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 }
|
ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 }
|
||||||
|
SelectionRounds = @($selectionRounds).Count
|
||||||
|
SelectionOfferings = @($selectionOfferings).Count
|
||||||
|
StudentEnrollments = $activeEnrollments.Count
|
||||||
|
RosterStudents = $selectionRoster.enrolledCount
|
||||||
AccessUpdate = $true
|
AccessUpdate = $true
|
||||||
ScopeChecks = $scopeChecks -join ', '
|
ScopeChecks = $scopeChecks -join ', '
|
||||||
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
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 Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -35,6 +36,14 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
|||||||
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
||||||
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
||||||
SchedulePlans = await db.SchedulePlans.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)
|
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<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
|
||||||
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
|
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
|
||||||
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
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>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
@@ -267,6 +272,51 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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 =>
|
builder.Entity<AuditLog>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Method).HasMaxLength(10);
|
entity.Property(x => x.Method).HasMaxLength(10);
|
||||||
|
|||||||
@@ -421,6 +421,42 @@ public sealed class DatabaseInitializer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await SeedDevelopmentUsersAsync(computerCollege.Id);
|
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)
|
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 TeachingTasksMigration = "20260724_03_teaching_tasks";
|
||||||
private const string SchedulesMigration = "20260724_04_schedules";
|
private const string SchedulesMigration = "20260724_04_schedules";
|
||||||
private const string ClassCounselorMigration = "20260724_05_class_counselor";
|
private const string ClassCounselorMigration = "20260724_05_class_counselor";
|
||||||
|
private const string CourseSelectionMigration = "20260724_06_course_selection";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -58,6 +59,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
? ClassCounselorStatements.Skip(1)
|
? ClassCounselorStatements.Skip(1)
|
||||||
: ClassCounselorStatements,
|
: ClassCounselorStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseSelectionMigration,
|
||||||
|
CourseSelectionStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -426,4 +431,79 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "AdministrativeClasses" ("CounselorUserId");
|
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");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+1664
File diff suppressed because it is too large
Load Diff
+145
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -382,6 +382,128 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("Courses");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1218,6 +1340,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("College");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||||
@@ -1453,6 +1624,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Students");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Courses");
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+1
@@ -16,6 +16,7 @@ declare module 'vue' {
|
|||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Reading,
|
Reading,
|
||||||
Tickets,
|
Tickets,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CircleCheck,
|
||||||
User,
|
User,
|
||||||
UserFilled,
|
UserFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
@@ -19,6 +20,16 @@ const router = useRouter()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
const mobileMenu = 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 pageTitle = computed(() => {
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
@@ -29,6 +40,7 @@ const pageTitle = computed(() => {
|
|||||||
curriculum: '培养方案',
|
curriculum: '培养方案',
|
||||||
'teaching-tasks': '教学任务',
|
'teaching-tasks': '教学任务',
|
||||||
schedules: '排课与课表',
|
schedules: '排课与课表',
|
||||||
|
'course-selections': '选课与教学班',
|
||||||
users: '用户与权限',
|
users: '用户与权限',
|
||||||
}
|
}
|
||||||
return titles[String(route.name)] ?? '教务管理'
|
return titles[String(route.name)] ?? '教务管理'
|
||||||
@@ -57,7 +69,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
|
|
||||||
<div v-if="!collapsed" class="term-stamp">
|
<div v-if="!collapsed" class="term-stamp">
|
||||||
<span>当前工作区</span>
|
<span>当前工作区</span>
|
||||||
<b>校级教务管理</b>
|
<b>{{ workspaceLabel }}</b>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu
|
<el-menu
|
||||||
@@ -71,7 +83,10 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
<el-icon><DataAnalysis /></el-icon>
|
<el-icon><DataAnalysis /></el-icon>
|
||||||
<template #title>教务总览</template>
|
<template #title>教务总览</template>
|
||||||
</el-menu-item>
|
</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>
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
<template #title>基础数据</template>
|
<template #title>基础数据</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
@@ -107,6 +122,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
<el-icon><Calendar /></el-icon>
|
<el-icon><Calendar /></el-icon>
|
||||||
<template #title>排课与课表</template>
|
<template #title>排课与课表</template>
|
||||||
</el-menu-item>
|
</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-menu-item v-if="auth.isSuperAdmin" index="/users">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<template #title>用户与权限</template>
|
<template #title>用户与权限</template>
|
||||||
@@ -115,7 +137,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
|
|
||||||
<div v-if="!collapsed" class="phase-note">
|
<div v-if="!collapsed" class="phase-note">
|
||||||
<span>第一阶段 · 核心可用版</span>
|
<span>第一阶段 · 核心可用版</span>
|
||||||
<p>主数据、培养方案与教学运行已就绪</p>
|
<p>教学运行、排课与学生选课已就绪</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const router = createRouter({
|
|||||||
path: 'base-data',
|
path: 'base-data',
|
||||||
name: 'base-data',
|
name: 'base-data',
|
||||||
component: () => import('../views/BaseDataView.vue'),
|
component: () => import('../views/BaseDataView.vue'),
|
||||||
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'personnel',
|
path: 'personnel',
|
||||||
@@ -57,6 +58,14 @@ const router = createRouter({
|
|||||||
component: () => import('../views/SchedulesView.vue'),
|
component: () => import('../views/SchedulesView.vue'),
|
||||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'course-selections',
|
||||||
|
name: 'course-selections',
|
||||||
|
component: () => import('../views/CourseSelectionView.vue'),
|
||||||
|
meta: {
|
||||||
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
name: 'users',
|
name: 'users',
|
||||||
@@ -75,6 +84,15 @@ router.beforeEach((to) => {
|
|||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' }
|
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
|
const roles = to.meta.roles as string[] | undefined
|
||||||
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
|
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
|
||||||
return { name: 'dashboard' }
|
return { name: 'dashboard' }
|
||||||
|
|||||||
@@ -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 { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; }
|
||||||
.schedule-card > button:hover { color: white; }
|
.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-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 { 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; }
|
.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 { flex-wrap: wrap; }
|
||||||
.schedule-search .el-input { width: 100%; }
|
.schedule-search .el-input { width: 100%; }
|
||||||
.schedule-search > span { width: 100%; margin-left: 0; }
|
.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; }
|
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||||
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
|
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
|
||||||
.el-dialog { width: calc(100vw - 24px) !important; }
|
.el-dialog { width: calc(100vw - 24px) !important; }
|
||||||
@@ -350,6 +486,21 @@ button { cursor: pointer; }
|
|||||||
.login-panel { padding: 32px 22px; background: white; }
|
.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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
|
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue'
|
import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue'
|
||||||
import http from '../api/http'
|
import http from '../api/http'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
currentTerm?: { name: string; startDate: string; endDate: string }
|
currentTerm?: { name: string; startDate: string; endDate: string }
|
||||||
@@ -10,6 +11,7 @@ interface DashboardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const data = ref<DashboardData>({ counts: {} })
|
const data = ref<DashboardData>({ counts: {} })
|
||||||
|
|
||||||
@@ -40,7 +42,11 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
<p v-else>请先在基础数据中建立学期档案。</p>
|
<p v-else>请先在基础数据中建立学期档案。</p>
|
||||||
</div>
|
</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>
|
<span>维护学期与组织数据</span>
|
||||||
<b>→</b>
|
<b>→</b>
|
||||||
</button>
|
</button>
|
||||||
@@ -101,18 +107,24 @@ onMounted(async () => {
|
|||||||
<b>{{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布</b>
|
<b>{{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布</b>
|
||||||
<i class="done">已建立</i>
|
<i class="done">已建立</i>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>学生选课</span>
|
||||||
|
<b>{{ data.counts.courseSelectionRounds ?? 0 }} 个批次 · {{ data.counts.courseEnrollments ?? 0 }} 条有效选课</b>
|
||||||
|
<i class="done">已建立</i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="work-card phase-card">
|
<article class="work-card phase-card">
|
||||||
<span class="section-kicker">NEXT MILESTONE</span>
|
<span class="section-kicker">NEXT MILESTONE</span>
|
||||||
<h3>下一段业务链</h3>
|
<h3>下一段业务链</h3>
|
||||||
<p>排课与课表发布链路已就绪,下一步进入学生选课轮次、容量与候补管理。</p>
|
<p>选课批次、容量校验和教学班名单已就绪,下一步进入成绩录入、审核与学业档案。</p>
|
||||||
<div class="phase-line">
|
<div class="phase-line">
|
||||||
<span class="active">基础底座</span>
|
<span class="active">基础底座</span>
|
||||||
<span class="active">人员档案</span>
|
<span class="active">人员档案</span>
|
||||||
<span class="active">培养方案</span>
|
<span class="active">培养方案</span>
|
||||||
<span class="active">排课课表</span>
|
<span class="active">排课课表</span>
|
||||||
|
<span class="active">学生选课</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user