已完成“选课候补与自动递补”完整流程。

学生端:满员后可加入/取消候补,展示候补顺位和当前候补人数。
自动递补:退课释放名额后,按候补时间顺序重新校验学籍、范围、学分及时间冲突;不合格者自动失效并继续下一位。
管理端:教学班显示候补人数,名单抽屉提供候补队列及移出操作。
状态通知:递补成功、资格失效、批次关闭、管理员移出均会通知学生。
并发安全:退课和递补放在 Serializable 事务中执行。
数据库:已补充 SQLite 开发迁移和 MySQL 正式迁移。
This commit is contained in:
2026-07-26 15:39:50 +08:00 Unverified
parent 65eed820a7
commit 56b13b3adb
12 changed files with 1203 additions and 79 deletions
+169 -21
View File
@@ -114,8 +114,8 @@ function conflictingSelectedCourses(offering: any) {
return selectedOfferings.value
.filter((selected) =>
selected.id !== offering.id
&& selected.schedules.some((existing: any) =>
offering.schedules.some((candidate: any) =>
&& (selected.schedules ?? []).some((existing: any) =>
(offering.schedules ?? []).some((candidate: any) =>
schedulesOverlap(candidate, existing),
),
),
@@ -123,14 +123,10 @@ function conflictingSelectedCourses(offering: any) {
.map((item) => item.courseName)
}
function offeringBlockReason(offering: any) {
if (offering.enrollmentStatus === 'Enrolled') return ''
function offeringEligibilityReason(offering: any) {
if (['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus)) return ''
if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内'
const effectiveCap = offering.isRetake
? Math.ceil(offering.capacity * 1.15)
: offering.capacity
if (offering.enrolledCount >= effectiveCap) return '教学班名额已满'
if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布'
if (!offering.isFlexible && !(offering.schedules ?? []).length) return '正式课表尚未发布'
if (!offering.isRetake && selectedOfferings.value.some((item) =>
item.id !== offering.id && item.courseCode === offering.courseCode,
)) return '本学期已选择同一课程'
@@ -147,9 +143,26 @@ function offeringBlockReason(offering: any) {
return ''
}
function effectiveCapacity(offering: any) {
return offering.isRetake
? Math.ceil(offering.capacity * 1.15)
: offering.capacity
}
function isOfferingFull(offering: any) {
return offering.enrolledCount >= effectiveCapacity(offering)
}
function offeringBlockReason(offering: any) {
const eligibilityReason = offeringEligibilityReason(offering)
if (eligibilityReason) return eligibilityReason
if (isOfferingFull(offering)) return '教学班名额已满,可加入候补'
return ''
}
function calcRetakeOverlapOk(offering: any): boolean {
let totalPeriods = 0, overlapPeriods = 0
for (const candidate of offering.schedules) {
for (const candidate of (offering.schedules ?? [])) {
totalPeriods += candidate.periodCount
for (const selected of selectedOfferings.value) {
for (const existing of (selected.schedules ?? [])) {
@@ -179,12 +192,15 @@ const filteredOfferings = computed(() => {
if (offeringStatus.value === 'Selected') {
return offering.enrollmentStatus === 'Enrolled'
}
const blocked = Boolean(offeringBlockReason(offering))
if (offeringStatus.value === 'Waitlisted') {
return offering.enrollmentStatus === 'Waitlisted'
}
const blocked = Boolean(offeringEligibilityReason(offering))
if (offeringStatus.value === 'Selectable') {
return offering.enrollmentStatus !== 'Enrolled' && !blocked
return !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && !blocked
}
if (offeringStatus.value === 'Blocked') {
return offering.enrollmentStatus !== 'Enrolled' && blocked
return !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && blocked
}
return true
})
@@ -328,6 +344,8 @@ async function loadRounds(keepSelection = true) {
async function selectRound(round: any) {
selectedRound.value = round
offerings.value = []
enrollments.value = []
previewOfferingId.value = ''
detailLoading.value = true
try {
@@ -652,6 +670,25 @@ async function removeFromRoster(student: any) {
}
}
async function removeFromWaitlist(student: any) {
if (!roster.value) return
try {
await ElMessageBox.confirm(
`确认将 ${student.studentNumber} ${student.name} 移出“${roster.value.courseName}”候补队列?`,
'调整候补队列',
{ type: 'warning', confirmButtonText: '确认移出', cancelButtonText: '取消' },
)
await http.delete(
`/course-selections/offerings/${roster.value.id}/waitlist/${student.id}`,
)
ElMessage.success('已移出候补队列')
await loadRoster(roster.value.id)
if (selectedRound.value) await selectRound(selectedRound.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function enroll(offering: any) {
try {
await http.post('/course-selections/student/enrollments', {
@@ -664,6 +701,18 @@ async function enroll(offering: any) {
}
}
async function joinWaitlist(offering: any) {
try {
const { data } = await http.post('/course-selections/student/waitlist', {
offeringId: offering.id,
})
ElMessage.success(`已加入“${offering.courseName}”候补,当前第 ${data.position}`)
await selectRound(selectedRound.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function togglePreview(offering: any) {
previewOfferingId.value = previewOfferingId.value === offering.id
? ''
@@ -691,6 +740,27 @@ async function withdraw(offering: any) {
}
}
async function cancelWaitlist(offering: any) {
const enrollment = enrollments.value.find(
(item) =>
item.courseSelectionOfferingId === offering.id &&
item.status === 'Waitlisted',
)
if (!enrollment) return
try {
await ElMessageBox.confirm(
`确定取消“${offering.courseName}”候补吗?取消后将失去当前顺位。`,
'取消候补',
{ type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '继续候补' },
)
await http.delete(`/course-selections/student/enrollments/${enrollment.id}`)
ElMessage.success('已取消候补')
await selectRound(selectedRound.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
try {
if (isManager.value) terms.value = (await http.get('/base-data/terms')).data
@@ -814,9 +884,12 @@ onMounted(async () => {
</template>
</el-table-column>
<el-table-column label="学分" width="75" prop="credits" />
<el-table-column label="名单 / 容量" width="125">
<el-table-column label="名单 / 容量" width="145">
<template #default="{ row }">
<b class="capacity-number">{{ row.enrolledCount }} / {{ row.capacity }}</b>
<small v-if="row.waitlistedCount" class="waitlist-count">
候补 {{ row.waitlistedCount }}
</small>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
@@ -962,6 +1035,7 @@ onMounted(async () => {
:options="[
{ label: '全部', value: 'All' },
{ label: '可选', value: 'Selectable' },
{ label: '候补', value: 'Waitlisted' },
{ label: '已选', value: 'Selected' },
{ label: '受限', value: 'Blocked' },
]"
@@ -977,8 +1051,10 @@ onMounted(async () => {
'offering-ticket',
{
selected: offering.enrollmentStatus === 'Enrolled',
waitlisted: offering.enrollmentStatus === 'Waitlisted',
previewing: offering.id === previewOfferingId,
blocked: offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering),
blocked: !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus)
&& offeringEligibilityReason(offering),
},
]"
>
@@ -994,19 +1070,24 @@ onMounted(async () => {
<i v-if="offering.enrollmentStatus === 'Enrolled'" class="selected-mark">
<el-icon><CircleCheck /></el-icon> 已选
</i>
<el-tag
v-else-if="offering.enrollmentStatus === 'Waitlisted'"
type="warning"
effect="dark"
>候补第 {{ offering.waitlistPosition }} </el-tag>
</header>
<div class="ticket-schedules">
<div v-for="schedule in offering.schedules" :key="formatSchedule(schedule)">
<div v-for="schedule in (offering.schedules ?? [])" :key="formatSchedule(schedule)">
<el-icon><Tickets /></el-icon>
<span>{{ formatSchedule(schedule) }}</span>
</div>
<span v-if="offering.isFlexible" class="schedule-missing">
非排时课程 · 不占正常时间与场地
</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
v-if="offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering)"
v-if="!['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && offeringBlockReason(offering)"
class="selection-block-reason"
>
{{ offeringBlockReason(offering) }}
@@ -1021,9 +1102,12 @@ onMounted(async () => {
<span>剩余 {{ Math.max(0, offering.capacity - offering.enrolledCount) }} / {{ offering.capacity }} </span>
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / offering.capacity * 100)}%` }" /></div>
</template>
<small v-if="offering.waitlistedCount">
当前候补 {{ offering.waitlistedCount }}
</small>
</div>
<el-button
v-if="offering.schedules.length || offering.isFlexible"
v-if="(offering.schedules ?? []).length || offering.isFlexible"
text
:type="offering.id === previewOfferingId ? 'warning' : 'primary'"
@click="togglePreview(offering)"
@@ -1035,10 +1119,22 @@ onMounted(async () => {
:disabled="selectedRound.status !== 'Open'"
@click="withdraw(offering)"
>退选</el-button>
<el-button
v-else-if="offering.enrollmentStatus === 'Waitlisted'"
type="warning"
plain
:disabled="selectedRound.status !== 'Open'"
@click="cancelWaitlist(offering)"
>取消候补</el-button>
<el-button
v-else-if="isOfferingFull(offering) && !offeringEligibilityReason(offering)"
type="warning"
@click="joinWaitlist(offering)"
>加入候补</el-button>
<el-button
v-else
type="primary"
:disabled="Boolean(offeringBlockReason(offering))"
:disabled="Boolean(offeringEligibilityReason(offering))"
@click="enroll(offering)"
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
</footer>
@@ -1144,7 +1240,10 @@ onMounted(async () => {
<div>
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
<b>{{ roster.taskName }}</b>
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} </small>
<small>
正式 {{ roster.enrolledCount }} / {{ roster.capacity }}
<template v-if="roster.waitlistedCount"> · 候补 {{ roster.waitlistedCount }} </template>
</small>
</div>
<div class="roster-actions">
<el-button
@@ -1190,6 +1289,35 @@ onMounted(async () => {
</el-table-column>
<template #empty><el-empty description="暂无学生选课" /></template>
</el-table>
<section class="waitlist-panel">
<div class="waitlist-panel-head">
<div>
<span>WAITLIST</span>
<b>候补队列</b>
</div>
<small>退课释放名额后系统按顺位重新校验并自动递补</small>
</div>
<el-table :data="roster.waitlist">
<el-table-column prop="position" label="顺位" width="70" />
<el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" />
<el-table-column prop="className" label="行政班" min-width="150" />
<el-table-column label="候补时间" min-width="130">
<template #default="{ row }">{{ formatDateTime(row.waitlistedAt) }}</template>
</el-table-column>
<el-table-column
v-if="roster.canManageWaitlist"
label="操作"
width="80"
fixed="right"
>
<template #default="{ row }">
<el-button link type="danger" @click="removeFromWaitlist(row)">移出</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无候补学生" /></template>
</el-table>
</section>
</template>
</el-drawer>
@@ -1313,4 +1441,24 @@ onMounted(async () => {
<style scoped>
.roster-actions { display: flex; gap: 8px; }
.force-notice { margin-top: 8px; }
.waitlist-count { display: block; margin-top: 3px; color: #b7791f; }
.offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); }
.seat-meter > small { display: block; margin-top: 5px; color: #b7791f; }
.waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
.waitlist-panel-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.waitlist-panel-head div { display: grid; gap: 3px; }
.waitlist-panel-head span { color: #b7791f; font: 700 9px/1 Consolas, monospace; letter-spacing: .16em; }
.waitlist-panel-head b { font-size: 17px; }
.waitlist-panel-head small { color: var(--muted); text-align: right; }
@media (max-width: 640px) {
.waitlist-panel-head { align-items: start; flex-direction: column; }
.waitlist-panel-head small { text-align: left; }
}
</style>