This commit is contained in:
2026-07-25 11:46:39 +08:00 Unverified
parent 533ff6b9f6
commit fdca6a3edf
8 changed files with 603 additions and 25 deletions
@@ -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(
public TestDataScope(Guid? restrictedCollegeId = null)
{
Current = new CurrentUserScope(
Guid.NewGuid(),
"测试管理员",
null,
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; }
}
}
+1
View File
@@ -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
View File
@@ -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; }
+347 -10
View File
@@ -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,14 +937,18 @@ 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="本轮没有适合你所在班级的课程" />
<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>