选课
This commit is contained in:
@@ -499,7 +499,8 @@ public sealed class CourseSelectionsController(
|
|||||||
round.AcademicTermId,
|
round.AcademicTermId,
|
||||||
[task.Id],
|
[task.Id],
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (candidateEntries.Count == 0)
|
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||||
|
candidateEntries.Count == 0)
|
||||||
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
|
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
|
||||||
|
|
||||||
foreach (var student in students)
|
foreach (var student in students)
|
||||||
@@ -804,7 +805,7 @@ public sealed class CourseSelectionsController(
|
|||||||
round.AcademicTermId,
|
round.AcademicTermId,
|
||||||
[task.Id],
|
[task.Id],
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
|
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||||
candidateEntries.Count == 0)
|
candidateEntries.Count == 0)
|
||||||
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
||||||
var selectedTaskIds = await db.CourseEnrollments
|
var selectedTaskIds = await db.CourseEnrollments
|
||||||
|
|||||||
@@ -95,6 +95,47 @@ public sealed class TeachingTasksController(
|
|||||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("options")]
|
||||||
|
public async Task<ActionResult> GetOptions(
|
||||||
|
Guid academicTermId,
|
||||||
|
TeachingTaskStatus status = TeachingTaskStatus.Published,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var items = await ScopedTasks()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.AcademicTermId == academicTermId &&
|
||||||
|
x.Status == status)
|
||||||
|
.OrderBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.Name,
|
||||||
|
x.AcademicTermId,
|
||||||
|
x.CourseId,
|
||||||
|
CourseCode = x.Course!.Code,
|
||||||
|
CourseName = x.Course.Name,
|
||||||
|
CourseNature = x.Course.Nature,
|
||||||
|
CollegeId = x.Course.CollegeId,
|
||||||
|
CollegeName = x.Course.College!.Name,
|
||||||
|
x.Capacity,
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
x.WeeklyHours,
|
||||||
|
x.SchedulingMode,
|
||||||
|
TeacherNames = x.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.Select(item => item.Teacher!.Name),
|
||||||
|
ClassNames = x.Classes
|
||||||
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||||
|
.Select(item => item.AdministrativeClass!.Name)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult> GetOne(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> GetOne(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public static class CourseSelectionRules
|
|||||||
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||||
nature == CourseNature.GeneralRequired;
|
nature == CourseNature.GeneralRequired;
|
||||||
|
|
||||||
|
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
||||||
|
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
||||||
|
|
||||||
public static bool HasScheduleConflict(
|
public static bool HasScheduleConflict(
|
||||||
IEnumerable<ScheduleEntry> candidateEntries,
|
IEnumerable<ScheduleEntry> candidateEntries,
|
||||||
IEnumerable<ScheduleEntry> selectedEntries) =>
|
IEnumerable<ScheduleEntry> selectedEntries) =>
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ public sealed class CourseSelectionRulesTests
|
|||||||
Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature));
|
Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(TeachingTaskSchedulingMode.Standard, true)]
|
||||||
|
[InlineData(TeachingTaskSchedulingMode.Flexible, false)]
|
||||||
|
public void Only_standard_courses_require_a_published_schedule(
|
||||||
|
TeachingTaskSchedulingMode schedulingMode,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
expected,
|
||||||
|
CourseSelectionRules.RequiresPublishedSchedule(schedulingMode));
|
||||||
|
}
|
||||||
|
|
||||||
private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) =>
|
private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,6 +65,68 @@ public sealed class TeachingTasksControllerTests
|
|||||||
task => Assert.Equal(TeachingTaskStatus.Published, task.Status));
|
task => Assert.Equal(TeachingTaskStatus.Published, task.Status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Options_ReturnsAllPublishedTasksWithoutListPageTruncation()
|
||||||
|
{
|
||||||
|
await using var database = await TestDatabase.CreateAsync();
|
||||||
|
for (var index = 1; index <= 125; index++)
|
||||||
|
{
|
||||||
|
await database.AddTaskAsync(
|
||||||
|
$"OPTION-{index:D3}",
|
||||||
|
TeachingTaskStatus.Published);
|
||||||
|
}
|
||||||
|
await database.AddTaskAsync("DRAFT", TeachingTaskStatus.Draft);
|
||||||
|
|
||||||
|
var result = await database.Controller.GetOptions(
|
||||||
|
database.TermId,
|
||||||
|
TeachingTaskStatus.Published,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
|
var items = Assert.IsAssignableFrom<System.Collections.IEnumerable>(ok.Value);
|
||||||
|
Assert.Equal(125, items.Cast<object>().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Options_PreservesCollegeDataScope()
|
||||||
|
{
|
||||||
|
await using var database = await TestDatabase.CreateAsync(collegeScoped: true);
|
||||||
|
await database.AddTaskAsync("IN-SCOPE", TeachingTaskStatus.Published);
|
||||||
|
var otherCollege = new College { Code = "OTHER", Name = "其他学院" };
|
||||||
|
var otherCourse = new Course
|
||||||
|
{
|
||||||
|
Code = "OTHER101",
|
||||||
|
Name = "范围外课程",
|
||||||
|
College = otherCollege,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32
|
||||||
|
};
|
||||||
|
database.Db.Add(new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "TASK-OUT-OF-SCOPE",
|
||||||
|
Name = "范围外教学任务",
|
||||||
|
AcademicTermId = database.TermId,
|
||||||
|
Course = otherCourse,
|
||||||
|
Capacity = 40,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeeklyHours = 2,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await database.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await database.Controller.GetOptions(
|
||||||
|
database.TermId,
|
||||||
|
TeachingTaskStatus.Published,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
|
var items = Assert.IsAssignableFrom<System.Collections.IEnumerable>(ok.Value);
|
||||||
|
Assert.Single(items.Cast<object>());
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class TestDatabase : IAsyncDisposable
|
private sealed class TestDatabase : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly SqliteConnection connection;
|
private readonly SqliteConnection connection;
|
||||||
@@ -75,7 +137,8 @@ public sealed class TeachingTasksControllerTests
|
|||||||
SqliteConnection connection,
|
SqliteConnection connection,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
AcademicTerm term,
|
AcademicTerm term,
|
||||||
Course course)
|
Course course,
|
||||||
|
ICurrentUserDataScope dataScope)
|
||||||
{
|
{
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
Db = db;
|
Db = db;
|
||||||
@@ -83,13 +146,14 @@ public sealed class TeachingTasksControllerTests
|
|||||||
this.course = course;
|
this.course = course;
|
||||||
Controller = new TeachingTasksController(
|
Controller = new TeachingTasksController(
|
||||||
db,
|
db,
|
||||||
new TestDataScope());
|
dataScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppDbContext Db { get; }
|
public AppDbContext Db { get; }
|
||||||
public TeachingTasksController Controller { get; }
|
public TeachingTasksController Controller { get; }
|
||||||
|
public Guid TermId => term.Id;
|
||||||
|
|
||||||
public static async Task<TestDatabase> CreateAsync()
|
public static async Task<TestDatabase> CreateAsync(bool collegeScoped = false)
|
||||||
{
|
{
|
||||||
var connection = new SqliteConnection("Data Source=:memory:");
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
@@ -120,7 +184,12 @@ public sealed class TeachingTasksControllerTests
|
|||||||
};
|
};
|
||||||
db.AddRange(college, term, course);
|
db.AddRange(college, term, course);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return new TestDatabase(connection, db, term, course);
|
return new TestDatabase(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
term,
|
||||||
|
course,
|
||||||
|
new TestDataScope(collegeScoped ? college.Id : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TeachingTask> AddTaskAsync(
|
public async Task<TeachingTask> AddTaskAsync(
|
||||||
@@ -156,11 +225,19 @@ public sealed class TeachingTasksControllerTests
|
|||||||
|
|
||||||
private sealed class TestDataScope : ICurrentUserDataScope
|
private sealed class TestDataScope : ICurrentUserDataScope
|
||||||
{
|
{
|
||||||
public CurrentUserScope Current { get; } = new(
|
public TestDataScope(Guid? restrictedCollegeId = null)
|
||||||
Guid.NewGuid(),
|
{
|
||||||
"测试管理员",
|
Current = new CurrentUserScope(
|
||||||
null,
|
Guid.NewGuid(),
|
||||||
DataScope.All,
|
"测试管理员",
|
||||||
new HashSet<string>([SystemRoles.SuperAdmin]));
|
restrictedCollegeId,
|
||||||
|
restrictedCollegeId.HasValue ? DataScope.College : DataScope.All,
|
||||||
|
new HashSet<string>(
|
||||||
|
restrictedCollegeId.HasValue
|
||||||
|
? [SystemRoles.CollegeAdmin]
|
||||||
|
: [SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CurrentUserScope Current { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -34,6 +34,7 @@ declare module 'vue' {
|
|||||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
|||||||
+108
-2
@@ -482,11 +482,105 @@ button { cursor: pointer; }
|
|||||||
.selection-ledger-head h3 { margin: 7px 0 4px; font-family: "STZhongsong", "Songti SC", serif; font-size: 19px; }
|
.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; }
|
.selection-ledger-head p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||||
.capacity-number { color: var(--indigo); font: 700 13px/1 Consolas, monospace; }
|
.capacity-number { color: var(--indigo); font: 700 13px/1 Consolas, monospace; }
|
||||||
|
.selection-timetable-card { min-width: 0; overflow: hidden; }
|
||||||
|
.selection-timetable-head,
|
||||||
|
.selection-catalog-head {
|
||||||
|
min-height: 92px; padding: 18px 21px; display: flex; align-items: center;
|
||||||
|
justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.selection-timetable-head > div,
|
||||||
|
.selection-catalog-head > div { min-width: 0; }
|
||||||
|
.selection-timetable-head span,
|
||||||
|
.selection-catalog-head span {
|
||||||
|
color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .14em;
|
||||||
|
}
|
||||||
|
.selection-timetable-head h3,
|
||||||
|
.selection-catalog-head h3 {
|
||||||
|
margin: 7px 0 4px; font-family: "STZhongsong", "Songti SC", serif; font-size: 19px;
|
||||||
|
}
|
||||||
|
.selection-timetable-head p,
|
||||||
|
.selection-catalog-head p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||||
|
.selection-preview-alert { margin: 14px 16px 0; width: auto; }
|
||||||
|
.selection-timetable-scroll { min-width: 0; overflow-x: auto; background: #f7f9fb; }
|
||||||
|
.selection-timetable-grid {
|
||||||
|
min-width: 970px; padding: 0 12px 14px; display: grid;
|
||||||
|
grid-template-columns: 62px repeat(7, minmax(120px, 1fr));
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.timetable-corner,
|
||||||
|
.timetable-day {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-bottom: 1px solid #cfd8e2; color: #566277; background: #edf2f6;
|
||||||
|
font-size: 10px; font-weight: 700; z-index: 3;
|
||||||
|
}
|
||||||
|
.timetable-corner { grid-column: 1; grid-row: 1; gap: 4px; color: var(--indigo); }
|
||||||
|
.timetable-day { border-left: 1px solid #d8e0e8; }
|
||||||
|
.timetable-period {
|
||||||
|
padding-right: 9px; display: flex; align-items: center; justify-content: flex-end; gap: 5px;
|
||||||
|
border-bottom: 1px solid #dfe5eb; color: #657084; background: #f2f5f8; z-index: 1;
|
||||||
|
}
|
||||||
|
.timetable-period b { color: var(--indigo); font: 700 15px/1 Consolas, monospace; }
|
||||||
|
.timetable-period span { font-size: 8px; }
|
||||||
|
.timetable-cell {
|
||||||
|
border-left: 1px solid #e0e6ec; border-bottom: 1px solid #e0e6ec;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(37,58,115,.018) 25%, transparent 25%) 0 0 / 8px 8px,
|
||||||
|
white;
|
||||||
|
}
|
||||||
|
.timetable-course {
|
||||||
|
min-width: 0; padding: 4px; display: flex; flex-direction: column; gap: 3px;
|
||||||
|
z-index: 2; pointer-events: none;
|
||||||
|
}
|
||||||
|
.timetable-course.multiple { flex-direction: row; }
|
||||||
|
.timetable-course article {
|
||||||
|
min-width: 0; min-height: 0; flex: 1; padding: 7px 8px; overflow: hidden;
|
||||||
|
border-left: 3px solid #566fc1; color: #283554; background: #e8edfb;
|
||||||
|
box-shadow: 0 2px 5px rgba(28,44,84,.08);
|
||||||
|
}
|
||||||
|
.timetable-course article.tone-1 { border-left-color: #2b988b; background: #e3f2ef; }
|
||||||
|
.timetable-course article.tone-2 { border-left-color: #c1812c; background: #f8eddc; }
|
||||||
|
.timetable-course article.tone-3 { border-left-color: #8a5aa8; background: #f0e8f4; }
|
||||||
|
.timetable-course article.tone-4 { border-left-color: #3982a3; background: #e4f0f5; }
|
||||||
|
.timetable-course article.preview {
|
||||||
|
outline: 2px dashed #d29135; outline-offset: -2px; background: #fff6df;
|
||||||
|
}
|
||||||
|
.timetable-course article.conflict {
|
||||||
|
border-left-color: #bd3e49; outline-color: #bd3e49; background: #fde8e9;
|
||||||
|
}
|
||||||
|
.timetable-course b,
|
||||||
|
.timetable-course span,
|
||||||
|
.timetable-course small {
|
||||||
|
display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.timetable-course b { font-size: 10px; line-height: 1.3; }
|
||||||
|
.timetable-course span { margin-top: 4px; color: #5e6880; font-size: 8px; }
|
||||||
|
.timetable-course small { margin-top: 3px; color: #697486; font-size: 8px; }
|
||||||
|
.flexible-course-row {
|
||||||
|
padding: 13px 18px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||||
|
border-top: 1px solid var(--line); background: white;
|
||||||
|
}
|
||||||
|
.flexible-course-row > b { margin-right: 4px; color: #4e596d; font-size: 10px; }
|
||||||
|
.flexible-course-row > span {
|
||||||
|
padding: 5px 8px; border: 1px solid #cad7df; color: #48606c; background: #f4f8f9;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.flexible-course-row > span.preview { border-style: dashed; border-color: #d29135; background: #fff6df; }
|
||||||
|
.selection-catalog { overflow: hidden; }
|
||||||
|
.selection-catalog-head > b {
|
||||||
|
flex: 0 0 auto; color: var(--indigo); font: 700 18px/1 Consolas, monospace;
|
||||||
|
}
|
||||||
|
.selection-catalog-filter {
|
||||||
|
padding: 13px 16px; display: grid; grid-template-columns: minmax(260px, 1fr) auto;
|
||||||
|
align-items: center; gap: 12px; background: #f8fafb;
|
||||||
|
}
|
||||||
|
.selection-catalog-filter .el-segmented { --el-segmented-item-selected-bg-color: var(--indigo); }
|
||||||
.offering-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
|
.offering-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
|
||||||
.offering-ticket {
|
.offering-ticket {
|
||||||
min-width: 0; min-height: 250px; display: flex; flex-direction: column;
|
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);
|
border: 1px solid var(--line); background: white; box-shadow: 0 6px 18px rgba(24,36,68,.045);
|
||||||
}
|
}
|
||||||
|
.offering-ticket.previewing { border-color: #d49a48; box-shadow: 0 0 0 2px rgba(212,154,72,.12); }
|
||||||
|
.offering-ticket.blocked:not(.selected) { background: #fbfcfd; }
|
||||||
.offering-ticket > header {
|
.offering-ticket > header {
|
||||||
min-height: 99px; padding: 19px 20px; display: flex; justify-content: space-between;
|
min-height: 99px; padding: 19px 20px; display: flex; justify-content: space-between;
|
||||||
gap: 16px; border-bottom: 1px dashed #d7dce4; position: relative;
|
gap: 16px; border-bottom: 1px dashed #d7dce4; position: relative;
|
||||||
@@ -512,11 +606,15 @@ button { cursor: pointer; }
|
|||||||
.ticket-schedules > div { display: flex; align-items: flex-start; gap: 7px; color: #4e596d; font-size: 10px; line-height: 1.5; }
|
.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); }
|
.ticket-schedules .el-icon { flex: 0 0 auto; margin-top: 1px; color: var(--indigo); }
|
||||||
.schedule-missing { color: #a36d22; font-size: 10px; }
|
.schedule-missing { color: #a36d22; font-size: 10px; }
|
||||||
|
.selection-block-reason {
|
||||||
|
margin: 0 20px 12px; padding: 8px 10px; border-left: 3px solid #bd3e49;
|
||||||
|
color: #90343d; background: #fbedee; font-size: 9px; line-height: 1.5;
|
||||||
|
}
|
||||||
.offering-ticket > footer {
|
.offering-ticket > footer {
|
||||||
margin-top: auto; min-height: 65px; padding: 13px 20px; display: flex; align-items: center;
|
margin-top: auto; min-height: 65px; padding: 13px 20px; display: flex; align-items: center;
|
||||||
gap: 18px; background: #fafbfc; border-top: 1px solid #edf0f4;
|
flex-wrap: wrap; gap: 10px; background: #fafbfc; border-top: 1px solid #edf0f4;
|
||||||
}
|
}
|
||||||
.seat-meter { flex: 1; min-width: 0; }
|
.seat-meter { flex: 1; min-width: 120px; }
|
||||||
.seat-meter > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 9px; }
|
.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 > div { height: 4px; overflow: hidden; background: #e2e6ec; }
|
||||||
.seat-meter i { height: 100%; display: block; background: var(--teal); }
|
.seat-meter i { height: 100%; display: block; background: var(--teal); }
|
||||||
@@ -1051,6 +1149,8 @@ button { cursor: pointer; }
|
|||||||
border-left: none;
|
border-left: none;
|
||||||
}
|
}
|
||||||
.round-actions { justify-content: flex-start; }
|
.round-actions { justify-content: flex-start; }
|
||||||
|
.selection-catalog-filter { grid-template-columns: 1fr; }
|
||||||
|
.selection-catalog-filter .el-segmented { width: 100%; }
|
||||||
.offering-grid { grid-template-columns: 1fr; }
|
.offering-grid { grid-template-columns: 1fr; }
|
||||||
.grade-workspace { display: block; }
|
.grade-workspace { display: block; }
|
||||||
.grade-sheet-list { max-height: 300px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
|
.grade-sheet-list { max-height: 300px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
|
||||||
@@ -1090,6 +1190,12 @@ button { cursor: pointer; }
|
|||||||
.window-copy em { display: block; margin: 7px 0 0; padding: 0; border: none; }
|
.window-copy em { display: block; margin: 7px 0 0; padding: 0; border: none; }
|
||||||
.credit-meter, .round-actions { padding: 18px; }
|
.credit-meter, .round-actions { padding: 18px; }
|
||||||
.selection-ledger-head { align-items: flex-start; flex-direction: column; }
|
.selection-ledger-head { align-items: flex-start; flex-direction: column; }
|
||||||
|
.selection-timetable-head,
|
||||||
|
.selection-catalog-head { align-items: flex-start; flex-direction: column; }
|
||||||
|
.selection-timetable-head .el-button { width: 100%; }
|
||||||
|
.selection-timetable-grid { min-width: 820px; }
|
||||||
|
.selection-catalog-filter .el-segmented { overflow-x: auto; }
|
||||||
|
.flexible-course-row { align-items: flex-start; flex-direction: column; }
|
||||||
.offering-ticket > footer { align-items: stretch; flex-direction: column; }
|
.offering-ticket > footer { align-items: stretch; flex-direction: column; }
|
||||||
.offering-ticket > footer .el-button { width: 100%; }
|
.offering-ticket > footer .el-button { width: 100%; }
|
||||||
.grade-toolbar { flex-wrap: wrap; }
|
.grade-toolbar { flex-wrap: wrap; }
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
|
Calendar,
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
Clock,
|
Clock,
|
||||||
Plus,
|
Plus,
|
||||||
Refresh,
|
Refresh,
|
||||||
|
Search,
|
||||||
Tickets,
|
Tickets,
|
||||||
UserFilled,
|
UserFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
@@ -43,6 +45,9 @@ const eligibleLoading = ref(false)
|
|||||||
const proxySubmitting = ref(false)
|
const proxySubmitting = ref(false)
|
||||||
const studentKeyword = ref('')
|
const studentKeyword = ref('')
|
||||||
const selectedStudentIds = ref<string[]>([])
|
const selectedStudentIds = ref<string[]>([])
|
||||||
|
const offeringKeyword = ref('')
|
||||||
|
const offeringStatus = ref('All')
|
||||||
|
const previewOfferingId = ref('')
|
||||||
const roundForm = reactive<Record<string, any>>({})
|
const roundForm = reactive<Record<string, any>>({})
|
||||||
const offeringForm = reactive<Record<string, any>>({})
|
const offeringForm = reactive<Record<string, any>>({})
|
||||||
|
|
||||||
@@ -69,6 +74,177 @@ const creditPercent = computed(() => {
|
|||||||
const selectedCount = computed(() =>
|
const selectedCount = computed(() =>
|
||||||
offerings.value.filter((item) => item.enrollmentStatus === 'Enrolled').length,
|
offerings.value.filter((item) => item.enrollmentStatus === 'Enrolled').length,
|
||||||
)
|
)
|
||||||
|
const selectedOfferings = computed(() =>
|
||||||
|
offerings.value.filter((item) => item.enrollmentStatus === 'Enrolled'),
|
||||||
|
)
|
||||||
|
const previewOffering = computed(() =>
|
||||||
|
offerings.value.find((item) => item.id === previewOfferingId.value) ?? null,
|
||||||
|
)
|
||||||
|
const selectableTasks = computed(() => {
|
||||||
|
const usedTaskIds = new Set(
|
||||||
|
offerings.value
|
||||||
|
.filter((item) => item.id !== editingOfferingId.value)
|
||||||
|
.map((item) => item.teachingTaskId),
|
||||||
|
)
|
||||||
|
return tasks.value.filter((item) => !usedTaskIds.has(item.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
function includesWeek(schedule: any, week: number) {
|
||||||
|
return schedule.weekPattern === 'All'
|
||||||
|
|| schedule.weekPattern === 'Odd' && week % 2 === 1
|
||||||
|
|| schedule.weekPattern === 'Even' && week % 2 === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulesOverlap(first: any, second: any) {
|
||||||
|
if (first.dayOfWeek !== second.dayOfWeek) return false
|
||||||
|
const periodsOverlap = first.startPeriod < second.startPeriod + second.periodCount
|
||||||
|
&& second.startPeriod < first.startPeriod + first.periodCount
|
||||||
|
if (!periodsOverlap) return false
|
||||||
|
const startWeek = Math.max(first.startWeek, second.startWeek)
|
||||||
|
const endWeek = Math.min(first.endWeek, second.endWeek)
|
||||||
|
for (let week = startWeek; week <= endWeek; week += 1) {
|
||||||
|
if (includesWeek(first, week) && includesWeek(second, week)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictingSelectedCourses(offering: any) {
|
||||||
|
if (offering.enrollmentStatus === 'Enrolled' || offering.isFlexible) return []
|
||||||
|
return selectedOfferings.value
|
||||||
|
.filter((selected) =>
|
||||||
|
selected.id !== offering.id
|
||||||
|
&& selected.schedules.some((existing: any) =>
|
||||||
|
offering.schedules.some((candidate: any) =>
|
||||||
|
schedulesOverlap(candidate, existing),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((item) => item.courseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function offeringBlockReason(offering: any) {
|
||||||
|
if (offering.enrollmentStatus === 'Enrolled') return ''
|
||||||
|
if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内'
|
||||||
|
if (offering.enrolledCount >= offering.capacity) return '教学班名额已满'
|
||||||
|
if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布'
|
||||||
|
if (selectedOfferings.value.some((item) =>
|
||||||
|
item.id !== offering.id && item.courseCode === offering.courseCode,
|
||||||
|
)) return '本学期已选择同一课程'
|
||||||
|
if (selectedCredits.value + Number(offering.credits) > Number(selectedRound.value.maxCredits)) {
|
||||||
|
return `选择后将超过 ${selectedRound.value.maxCredits} 学分上限`
|
||||||
|
}
|
||||||
|
const conflicts = conflictingSelectedCourses(offering)
|
||||||
|
return conflicts.length ? `与已选“${conflicts.join('、')}”时间冲突` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOfferings = computed(() => {
|
||||||
|
const keyword = offeringKeyword.value.trim().toLocaleLowerCase()
|
||||||
|
return offerings.value.filter((offering) => {
|
||||||
|
const matchesKeyword = !keyword || [
|
||||||
|
offering.courseCode,
|
||||||
|
offering.courseName,
|
||||||
|
offering.taskNumber,
|
||||||
|
...(offering.teacherNames ?? []),
|
||||||
|
].some((value) => String(value ?? '').toLocaleLowerCase().includes(keyword))
|
||||||
|
if (!matchesKeyword) return false
|
||||||
|
if (offeringStatus.value === 'Selected') {
|
||||||
|
return offering.enrollmentStatus === 'Enrolled'
|
||||||
|
}
|
||||||
|
const blocked = Boolean(offeringBlockReason(offering))
|
||||||
|
if (offeringStatus.value === 'Selectable') {
|
||||||
|
return offering.enrollmentStatus !== 'Enrolled' && !blocked
|
||||||
|
}
|
||||||
|
if (offeringStatus.value === 'Blocked') {
|
||||||
|
return offering.enrollmentStatus !== 'Enrolled' && blocked
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const timetableOfferings = computed(() => {
|
||||||
|
const result = [...selectedOfferings.value]
|
||||||
|
if (previewOffering.value
|
||||||
|
&& previewOffering.value.enrollmentStatus !== 'Enrolled') {
|
||||||
|
result.push(previewOffering.value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const timetablePeriodCount = computed(() => Math.max(
|
||||||
|
12,
|
||||||
|
...timetableOfferings.value.flatMap((offering) =>
|
||||||
|
offering.schedules.map((schedule: any) =>
|
||||||
|
schedule.startPeriod + schedule.periodCount - 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
const timetablePeriods = computed(() =>
|
||||||
|
Array.from({ length: timetablePeriodCount.value }, (_, index) => index + 1),
|
||||||
|
)
|
||||||
|
const timetableCells = computed(() =>
|
||||||
|
timetablePeriods.value.flatMap((period) =>
|
||||||
|
Array.from({ length: 7 }, (_, index) => ({
|
||||||
|
key: `${index + 1}-${period}`,
|
||||||
|
dayOfWeek: index + 1,
|
||||||
|
period,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const timetableGroups = computed(() => {
|
||||||
|
const entriesByDay = new Map<number, any[]>()
|
||||||
|
timetableOfferings.value.forEach((offering, offeringIndex) => {
|
||||||
|
offering.schedules.forEach((schedule: any) => {
|
||||||
|
const dayEntries = entriesByDay.get(schedule.dayOfWeek) ?? []
|
||||||
|
dayEntries.push({
|
||||||
|
...schedule,
|
||||||
|
courseName: offering.courseName,
|
||||||
|
taskNumber: offering.taskNumber,
|
||||||
|
isPreview: offering.id === previewOfferingId.value,
|
||||||
|
hasConflict: offering.id === previewOfferingId.value
|
||||||
|
&& conflictingSelectedCourses(offering).length > 0,
|
||||||
|
tone: offeringIndex % 5,
|
||||||
|
})
|
||||||
|
entriesByDay.set(schedule.dayOfWeek, dayEntries)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const groups: any[] = []
|
||||||
|
entriesByDay.forEach((entries, dayOfWeek) => {
|
||||||
|
entries
|
||||||
|
.sort((first, second) =>
|
||||||
|
first.startPeriod - second.startPeriod
|
||||||
|
|| first.periodCount - second.periodCount,
|
||||||
|
)
|
||||||
|
.forEach((entry) => {
|
||||||
|
const endPeriod = entry.startPeriod + entry.periodCount
|
||||||
|
const current = groups.at(-1)
|
||||||
|
if (current
|
||||||
|
&& current.dayOfWeek === dayOfWeek
|
||||||
|
&& entry.startPeriod < current.endPeriod) {
|
||||||
|
current.endPeriod = Math.max(current.endPeriod, endPeriod)
|
||||||
|
current.periodCount = current.endPeriod - current.startPeriod
|
||||||
|
current.entries.push(entry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groups.push({
|
||||||
|
key: `${dayOfWeek}-${entry.startPeriod}-${groups.length}`,
|
||||||
|
dayOfWeek,
|
||||||
|
startPeriod: entry.startPeriod,
|
||||||
|
endPeriod,
|
||||||
|
periodCount: entry.periodCount,
|
||||||
|
entries: [entry],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
const flexibleTimetableOfferings = computed(() =>
|
||||||
|
timetableOfferings.value.filter((offering) => offering.isFlexible),
|
||||||
|
)
|
||||||
|
const previewConflictNames = computed(() =>
|
||||||
|
previewOffering.value
|
||||||
|
? conflictingSelectedCourses(previewOffering.value)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string) {
|
||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
@@ -121,6 +297,7 @@ async function loadRounds(keepSelection = true) {
|
|||||||
|
|
||||||
async function selectRound(round: any) {
|
async function selectRound(round: any) {
|
||||||
selectedRound.value = round
|
selectedRound.value = round
|
||||||
|
previewOfferingId.value = ''
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (isStudent.value) {
|
if (isStudent.value) {
|
||||||
@@ -146,15 +323,13 @@ async function selectRound(round: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks(academicTermId: string) {
|
async function loadTasks(academicTermId: string) {
|
||||||
const { data } = await http.get('/teaching-tasks', {
|
const { data } = await http.get('/teaching-tasks/options', {
|
||||||
params: {
|
params: {
|
||||||
academicTermId,
|
academicTermId,
|
||||||
status: 'Published',
|
status: 'Published',
|
||||||
page: 1,
|
|
||||||
pageSize: 100,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
tasks.value = data.items
|
tasks.value = data
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRound(round?: any) {
|
function openRound(round?: any) {
|
||||||
@@ -401,6 +576,12 @@ async function enroll(offering: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePreview(offering: any) {
|
||||||
|
previewOfferingId.value = previewOfferingId.value === offering.id
|
||||||
|
? ''
|
||||||
|
: offering.id
|
||||||
|
}
|
||||||
|
|
||||||
async function withdraw(offering: any) {
|
async function withdraw(offering: any) {
|
||||||
const enrollment = enrollments.value.find(
|
const enrollment = enrollments.value.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -567,8 +748,148 @@ onMounted(async () => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="offering-grid" v-loading="detailLoading">
|
<template v-else>
|
||||||
<article v-for="offering in offerings" :key="offering.id" class="offering-ticket">
|
<section class="data-card selection-timetable-card" v-loading="detailLoading">
|
||||||
|
<div class="selection-timetable-head">
|
||||||
|
<div>
|
||||||
|
<span>WEEKLY ARRANGEMENT</span>
|
||||||
|
<h3>已选课程表</h3>
|
||||||
|
<p>
|
||||||
|
{{ selectedCount ? `已排入 ${selectedCount} 门课程` : '选择课程后将在此形成周课表' }}
|
||||||
|
<template v-if="previewOffering">
|
||||||
|
· 正在试排“{{ previewOffering.courseName }}”
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="previewOffering"
|
||||||
|
plain
|
||||||
|
@click="previewOfferingId = ''"
|
||||||
|
>结束试排</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="previewConflictNames.length"
|
||||||
|
class="selection-preview-alert"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
:title="`试排课程与“${previewConflictNames.join('、')}”时间冲突,不能同时选择。`"
|
||||||
|
/>
|
||||||
|
<div class="selection-timetable-scroll">
|
||||||
|
<div
|
||||||
|
class="selection-timetable-grid"
|
||||||
|
:style="{ gridTemplateRows: `42px repeat(${timetablePeriodCount}, 64px)` }"
|
||||||
|
>
|
||||||
|
<div class="timetable-corner">
|
||||||
|
<el-icon><Calendar /></el-icon>
|
||||||
|
节次
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="day in 7"
|
||||||
|
:key="`day-${day}`"
|
||||||
|
class="timetable-day"
|
||||||
|
:style="{ gridColumn: day + 1, gridRow: 1 }"
|
||||||
|
>{{ weekdayLabels[day] }}</div>
|
||||||
|
<div
|
||||||
|
v-for="period in timetablePeriods"
|
||||||
|
:key="`period-${period}`"
|
||||||
|
class="timetable-period"
|
||||||
|
:style="{ gridColumn: 1, gridRow: period + 1 }"
|
||||||
|
>
|
||||||
|
<b>{{ period }}</b>
|
||||||
|
<span>第 {{ period }} 节</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="cell in timetableCells"
|
||||||
|
:key="cell.key"
|
||||||
|
class="timetable-cell"
|
||||||
|
:style="{
|
||||||
|
gridColumn: cell.dayOfWeek + 1,
|
||||||
|
gridRow: cell.period + 1,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="group in timetableGroups"
|
||||||
|
:key="group.key"
|
||||||
|
:class="['timetable-course', { multiple: group.entries.length > 1 }]"
|
||||||
|
:style="{
|
||||||
|
gridColumn: group.dayOfWeek + 1,
|
||||||
|
gridRow: `${group.startPeriod + 1} / span ${group.periodCount}`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="entry in group.entries"
|
||||||
|
:key="`${entry.taskNumber}-${entry.startWeek}-${entry.weekPattern}`"
|
||||||
|
:class="[
|
||||||
|
`tone-${entry.tone}`,
|
||||||
|
{ preview: entry.isPreview, conflict: entry.hasConflict },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<b>{{ entry.courseName }}</b>
|
||||||
|
<span>
|
||||||
|
{{ entry.startWeek }}—{{ entry.endWeek }} 周
|
||||||
|
{{ patternLabels[entry.weekPattern] === '每周' ? '' : patternLabels[entry.weekPattern] }}
|
||||||
|
</span>
|
||||||
|
<small>{{ entry.classroomName }}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="flexibleTimetableOfferings.length"
|
||||||
|
class="flexible-course-row"
|
||||||
|
>
|
||||||
|
<b>非排时课程</b>
|
||||||
|
<span
|
||||||
|
v-for="offering in flexibleTimetableOfferings"
|
||||||
|
:key="offering.id"
|
||||||
|
:class="{ preview: offering.id === previewOfferingId }"
|
||||||
|
>
|
||||||
|
{{ offering.courseName }} · 不占固定节次与教室
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="data-card selection-catalog">
|
||||||
|
<div class="selection-catalog-head">
|
||||||
|
<div>
|
||||||
|
<span>AVAILABLE CLASSES</span>
|
||||||
|
<h3>本轮可选教学班</h3>
|
||||||
|
<p>先试排再选课;冲突、容量和学分限制会提前显示,提交时服务端会再次校验。</p>
|
||||||
|
</div>
|
||||||
|
<b>{{ filteredOfferings.length }} / {{ offerings.length }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="selection-catalog-filter">
|
||||||
|
<el-input
|
||||||
|
v-model="offeringKeyword"
|
||||||
|
:prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索课程代码、课程名称、教学班或教师"
|
||||||
|
/>
|
||||||
|
<el-segmented
|
||||||
|
v-model="offeringStatus"
|
||||||
|
:options="[
|
||||||
|
{ label: '全部', value: 'All' },
|
||||||
|
{ label: '可选', value: 'Selectable' },
|
||||||
|
{ label: '已选', value: 'Selected' },
|
||||||
|
{ label: '受限', value: 'Blocked' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="offering-grid" v-loading="detailLoading">
|
||||||
|
<article
|
||||||
|
v-for="offering in filteredOfferings"
|
||||||
|
:key="offering.id"
|
||||||
|
:class="[
|
||||||
|
'offering-ticket',
|
||||||
|
{
|
||||||
|
selected: offering.enrollmentStatus === 'Enrolled',
|
||||||
|
previewing: offering.id === previewOfferingId,
|
||||||
|
blocked: offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
||||||
@@ -589,11 +910,23 @@ onMounted(async () => {
|
|||||||
</span>
|
</span>
|
||||||
<span v-else-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
|
<span v-else-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering)"
|
||||||
|
class="selection-block-reason"
|
||||||
|
>
|
||||||
|
{{ offeringBlockReason(offering) }}
|
||||||
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<div class="seat-meter">
|
<div class="seat-meter">
|
||||||
<span>剩余 {{ Math.max(0, offering.capacity - offering.enrolledCount) }} / {{ offering.capacity }} 席</span>
|
<span>剩余 {{ Math.max(0, offering.capacity - offering.enrolledCount) }} / {{ offering.capacity }} 席</span>
|
||||||
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / offering.capacity * 100)}%` }" /></div>
|
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / offering.capacity * 100)}%` }" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="offering.schedules.length || offering.isFlexible"
|
||||||
|
text
|
||||||
|
:type="offering.id === previewOfferingId ? 'warning' : 'primary'"
|
||||||
|
@click="togglePreview(offering)"
|
||||||
|
>{{ offering.id === previewOfferingId ? '取消试排' : '课表试排' }}</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="offering.enrollmentStatus === 'Enrolled'"
|
v-if="offering.enrollmentStatus === 'Enrolled'"
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -604,13 +937,17 @@ onMounted(async () => {
|
|||||||
<el-button
|
<el-button
|
||||||
v-else
|
v-else
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || (!offering.isFlexible && !offering.schedules.length)"
|
:disabled="Boolean(offeringBlockReason(offering))"
|
||||||
@click="enroll(offering)"
|
@click="enroll(offering)"
|
||||||
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
|
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!offerings.length" description="本轮没有适合你所在班级的课程" />
|
<el-empty
|
||||||
</section>
|
v-if="!filteredOfferings.length"
|
||||||
|
:description="offerings.length ? '没有符合当前筛选条件的教学班' : '本轮没有适合你所在班级的课程'"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-empty v-else-if="!loading" description="暂无选课批次" />
|
<el-empty v-else-if="!loading" description="暂无选课批次" />
|
||||||
@@ -670,9 +1007,9 @@ onMounted(async () => {
|
|||||||
@change="onTaskChanged"
|
@change="onTaskChanged"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="task in tasks"
|
v-for="task in selectableTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}`"
|
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}${task.schedulingMode === 'Flexible' ? '(非排时)' : ''}`"
|
||||||
:value="task.id"
|
:value="task.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|||||||
Reference in New Issue
Block a user