选课
This commit is contained in:
@@ -499,7 +499,8 @@ public sealed class CourseSelectionsController(
|
||||
round.AcademicTermId,
|
||||
[task.Id],
|
||||
cancellationToken);
|
||||
if (candidateEntries.Count == 0)
|
||||
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||
candidateEntries.Count == 0)
|
||||
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
|
||||
|
||||
foreach (var student in students)
|
||||
@@ -804,7 +805,7 @@ public sealed class CourseSelectionsController(
|
||||
round.AcademicTermId,
|
||||
[task.Id],
|
||||
cancellationToken);
|
||||
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
|
||||
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
|
||||
candidateEntries.Count == 0)
|
||||
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
|
||||
var selectedTaskIds = await db.CourseEnrollments
|
||||
|
||||
@@ -95,6 +95,47 @@ public sealed class TeachingTasksController(
|
||||
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}")]
|
||||
public async Task<ActionResult> GetOne(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,9 @@ public static class CourseSelectionRules
|
||||
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||
nature == CourseNature.GeneralRequired;
|
||||
|
||||
public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) =>
|
||||
schedulingMode == TeachingTaskSchedulingMode.Standard;
|
||||
|
||||
public static bool HasScheduleConflict(
|
||||
IEnumerable<ScheduleEntry> candidateEntries,
|
||||
IEnumerable<ScheduleEntry> selectedEntries) =>
|
||||
|
||||
@@ -66,6 +66,18 @@ public sealed class CourseSelectionRulesTests
|
||||
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) =>
|
||||
new()
|
||||
{
|
||||
|
||||
@@ -65,6 +65,68 @@ public sealed class TeachingTasksControllerTests
|
||||
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 readonly SqliteConnection connection;
|
||||
@@ -75,7 +137,8 @@ public sealed class TeachingTasksControllerTests
|
||||
SqliteConnection connection,
|
||||
AppDbContext db,
|
||||
AcademicTerm term,
|
||||
Course course)
|
||||
Course course,
|
||||
ICurrentUserDataScope dataScope)
|
||||
{
|
||||
this.connection = connection;
|
||||
Db = db;
|
||||
@@ -83,13 +146,14 @@ public sealed class TeachingTasksControllerTests
|
||||
this.course = course;
|
||||
Controller = new TeachingTasksController(
|
||||
db,
|
||||
new TestDataScope());
|
||||
dataScope);
|
||||
}
|
||||
|
||||
public AppDbContext Db { 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:");
|
||||
await connection.OpenAsync();
|
||||
@@ -120,7 +184,12 @@ public sealed class TeachingTasksControllerTests
|
||||
};
|
||||
db.AddRange(college, term, course);
|
||||
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(
|
||||
@@ -156,11 +225,19 @@ public sealed class TeachingTasksControllerTests
|
||||
|
||||
private sealed class TestDataScope : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
Guid.NewGuid(),
|
||||
"测试管理员",
|
||||
null,
|
||||
DataScope.All,
|
||||
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||
public TestDataScope(Guid? restrictedCollegeId = null)
|
||||
{
|
||||
Current = new CurrentUserScope(
|
||||
Guid.NewGuid(),
|
||||
"测试管理员",
|
||||
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']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
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 p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.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-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.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 {
|
||||
min-height: 99px; padding: 19px 20px; display: flex; justify-content: space-between;
|
||||
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 .el-icon { flex: 0 0 auto; margin-top: 1px; color: var(--indigo); }
|
||||
.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 {
|
||||
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 > div { height: 4px; overflow: hidden; background: #e2e6ec; }
|
||||
.seat-meter i { height: 100%; display: block; background: var(--teal); }
|
||||
@@ -1051,6 +1149,8 @@ button { cursor: pointer; }
|
||||
border-left: none;
|
||||
}
|
||||
.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; }
|
||||
.grade-workspace { display: block; }
|
||||
.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; }
|
||||
.credit-meter, .round-actions { padding: 18px; }
|
||||
.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 .el-button { width: 100%; }
|
||||
.grade-toolbar { flex-wrap: wrap; }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Calendar,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Tickets,
|
||||
UserFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -43,6 +45,9 @@ const eligibleLoading = ref(false)
|
||||
const proxySubmitting = ref(false)
|
||||
const studentKeyword = ref('')
|
||||
const selectedStudentIds = ref<string[]>([])
|
||||
const offeringKeyword = ref('')
|
||||
const offeringStatus = ref('All')
|
||||
const previewOfferingId = ref('')
|
||||
const roundForm = reactive<Record<string, any>>({})
|
||||
const offeringForm = reactive<Record<string, any>>({})
|
||||
|
||||
@@ -69,6 +74,177 @@ const creditPercent = computed(() => {
|
||||
const selectedCount = computed(() =>
|
||||
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) {
|
||||
if (!value) return '—'
|
||||
@@ -121,6 +297,7 @@ async function loadRounds(keepSelection = true) {
|
||||
|
||||
async function selectRound(round: any) {
|
||||
selectedRound.value = round
|
||||
previewOfferingId.value = ''
|
||||
detailLoading.value = true
|
||||
try {
|
||||
if (isStudent.value) {
|
||||
@@ -146,15 +323,13 @@ async function selectRound(round: any) {
|
||||
}
|
||||
|
||||
async function loadTasks(academicTermId: string) {
|
||||
const { data } = await http.get('/teaching-tasks', {
|
||||
const { data } = await http.get('/teaching-tasks/options', {
|
||||
params: {
|
||||
academicTermId,
|
||||
status: 'Published',
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
},
|
||||
})
|
||||
tasks.value = data.items
|
||||
tasks.value = data
|
||||
}
|
||||
|
||||
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) {
|
||||
const enrollment = enrollments.value.find(
|
||||
(item) =>
|
||||
@@ -567,8 +748,148 @@ onMounted(async () => {
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section v-else class="offering-grid" v-loading="detailLoading">
|
||||
<article v-for="offering in offerings" :key="offering.id" class="offering-ticket">
|
||||
<template v-else>
|
||||
<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>
|
||||
<div>
|
||||
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
||||
@@ -589,11 +910,23 @@ onMounted(async () => {
|
||||
</span>
|
||||
<span v-else-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering)"
|
||||
class="selection-block-reason"
|
||||
>
|
||||
{{ offeringBlockReason(offering) }}
|
||||
</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.schedules.length || offering.isFlexible"
|
||||
text
|
||||
:type="offering.id === previewOfferingId ? 'warning' : 'primary'"
|
||||
@click="togglePreview(offering)"
|
||||
>{{ offering.id === previewOfferingId ? '取消试排' : '课表试排' }}</el-button>
|
||||
<el-button
|
||||
v-if="offering.enrollmentStatus === 'Enrolled'"
|
||||
type="danger"
|
||||
@@ -604,13 +937,17 @@ onMounted(async () => {
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || (!offering.isFlexible && !offering.schedules.length)"
|
||||
:disabled="Boolean(offeringBlockReason(offering))"
|
||||
@click="enroll(offering)"
|
||||
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
<el-empty v-if="!offerings.length" description="本轮没有适合你所在班级的课程" />
|
||||
</section>
|
||||
<el-empty
|
||||
v-if="!filteredOfferings.length"
|
||||
:description="offerings.length ? '没有符合当前筛选条件的教学班' : '本轮没有适合你所在班级的课程'"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<el-empty v-else-if="!loading" description="暂无选课批次" />
|
||||
@@ -670,9 +1007,9 @@ onMounted(async () => {
|
||||
@change="onTaskChanged"
|
||||
>
|
||||
<el-option
|
||||
v-for="task in tasks"
|
||||
v-for="task in selectableTasks"
|
||||
:key="task.id"
|
||||
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}`"
|
||||
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}${task.schedulingMode === 'Flexible' ? '(非排时)' : ''}`"
|
||||
:value="task.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
Reference in New Issue
Block a user