Files
Academic-Affairs-System/web/src/views/CourseSelectionView.vue
T
biss b0679a253d 成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
2026-07-26 19:12:42 +08:00

1548 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const managerRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']
const isManager = computed(() =>
auth.user?.roles.some((role) => managerRoles.includes(role)) ?? false,
)
const canManageRounds = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
)
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
const terms = ref<any[]>([])
const gradeOptions = ref<number[]>([])
const rounds = ref<any[]>([])
const selectedRound = ref<any | null>(null)
const offerings = ref<any[]>([])
const tasks = ref<any[]>([])
const enrollments = ref<any[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const roundDialog = ref(false)
const offeringDialog = ref(false)
const rosterDrawer = ref(false)
const proxyDialog = ref(false)
const editingRoundId = ref('')
const editingOfferingId = ref('')
const roster = ref<any | null>(null)
const rosterLoading = ref(false)
const eligibleStudents = ref<any[]>([])
const eligibleTotal = ref(0)
const eligiblePage = ref(1)
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>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿',
Open: '开放中',
Closed: '已关闭',
}
const patternLabels: Record<string, string> = {
All: '每周',
Odd: '单周',
Even: '双周',
}
const weekdayLabels = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const selectedCredits = computed(() =>
offerings.value
.filter((item) => item.enrollmentStatus === 'Enrolled')
.reduce((sum, item) => sum + Number(item.credits), 0),
)
const creditPercent = computed(() => {
const maximum = Number(selectedRound.value?.maxCredits || 1)
return Math.min(100, Math.round((selectedCredits.value / maximum) * 100))
})
const selectedCount = computed(() =>
offerings.value.filter((item) => item.enrollmentStatus === 'Enrolled').length,
)
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 offeringEligibilityReason(offering: any) {
if (['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus)) return ''
if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内'
if (!offering.isFlexible && !(offering.schedules ?? []).length) return '正式课表尚未发布'
if (!offering.isRetake && selectedOfferings.value.some((item) =>
item.id !== offering.id && item.courseCode === offering.courseCode,
)) return '本学期已选择同一课程'
if (selectedRound.value.maxCourseCount
&& selectedCount.value >= Number(selectedRound.value.maxCourseCount)) {
return `已达到本轮最多 ${selectedRound.value.maxCourseCount} 门课程限制`
}
if (selectedCredits.value + Number(offering.credits) > Number(selectedRound.value.maxCredits)) {
return `选择后将超过 ${selectedRound.value.maxCredits} 学分上限`
}
const conflicts = conflictingSelectedCourses(offering)
if (conflicts.length) {
if (!offering.isRetake) return `与已选”${conflicts.join('、')}”时间冲突`
// Retake: calculate overlap (client-side rough estimate)
if (!calcRetakeOverlapOk(offering)) return `重修时间冲突超过 50%,无法选课`
return '' // retake with acceptable overlap
}
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 ?? [])) {
totalPeriods += candidate.periodCount
for (const selected of selectedOfferings.value) {
for (const existing of (selected.schedules ?? [])) {
if (candidate.dayOfWeek !== existing.dayOfWeek) continue
const overlapStart = Math.max(candidate.startPeriod, existing.startPeriod)
const overlapEnd = Math.min(
candidate.startPeriod + candidate.periodCount,
existing.startPeriod + existing.periodCount,
)
if (overlapEnd > overlapStart) overlapPeriods += overlapEnd - overlapStart
}
}
}
return totalPeriods === 0 || (overlapPeriods / totalPeriods * 100) <= 50
}
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'
}
if (offeringStatus.value === 'Waitlisted') {
return offering.enrollmentStatus === 'Waitlisted'
}
const blocked = Boolean(offeringEligibilityReason(offering))
if (offeringStatus.value === 'Selectable') {
return !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && !blocked
}
if (offeringStatus.value === 'Blocked') {
return !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && 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 '—'
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(value))
}
function roundGradeLabel(round: any) {
const grades = (round?.eligibleGrades ?? []).map(Number)
return grades.length ? grades.map((grade: number) => `${grade} 级`).join('、') : '全部年级'
}
function roundLimitLabel(round: any) {
const courseLimit = round?.maxCourseCount
? `最多 ${round.maxCourseCount} 门`
: '课程门数不限'
return `${roundGradeLabel(round)} · ${courseLimit} · 最多 ${round.maxCredits} 学分`
}
function toPickerValue(value: string) {
if (!value) return ''
const date = new Date(value)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:00`
}
function toIso(value: string) {
return new Date(value.replace(' ', 'T')).toISOString()
}
function formatSchedule(schedule: any) {
const periods = schedule.periodCount === 1
? `第 ${schedule.startPeriod} 节`
: `第 ${schedule.startPeriod}${schedule.startPeriod + schedule.periodCount - 1} 节`
return `${weekdayLabels[schedule.dayOfWeek]} ${periods} · ${schedule.startWeek}${schedule.endWeek}${patternLabels[schedule.weekPattern] === '每周' ? '' : ` · ${patternLabels[schedule.weekPattern]}`} · ${schedule.classroomName}`
}
async function loadRounds(keepSelection = true) {
loading.value = true
try {
rounds.value = (await http.get('/course-selections/rounds')).data
const previousId = keepSelection ? selectedRound.value?.id : undefined
const preferred = rounds.value.find((item) => item.id === previousId)
?? rounds.value.find((item) => item.isAvailableNow && item.termIsCurrent)
?? rounds.value.find((item) => item.termIsCurrent)
?? rounds.value.find((item) => item.isAvailableNow)
?? rounds.value[0]
if (preferred) await selectRound(preferred)
else {
selectedRound.value = null
offerings.value = []
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectRound(round: any) {
selectedRound.value = round
offerings.value = []
enrollments.value = []
previewOfferingId.value = ''
detailLoading.value = true
try {
if (isStudent.value) {
const [optionResponse, enrollmentResponse] = await Promise.all([
http.get('/course-selections/student/options', { params: { roundId: round.id } }),
http.get('/course-selections/student/enrollments', {
params: { academicTermId: round.academicTermId },
}),
])
offerings.value = optionResponse.data.offerings
enrollments.value = enrollmentResponse.data
} else {
offerings.value = (
await http.get(`/course-selections/rounds/${round.id}/offerings`)
).data
if (round.status === 'Draft') await loadTasks(round.academicTermId)
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
async function loadTasks(academicTermId: string) {
const { data } = await http.get('/teaching-tasks/options', {
params: {
academicTermId,
status: 'Published',
},
})
tasks.value = data
}
function openRound(round?: any) {
editingRoundId.value = round?.id ?? ''
const now = new Date()
const ends = new Date(now.getTime() + 7 * 86400000)
const withdrawal = new Date(now.getTime() + 14 * 86400000)
Object.assign(roundForm, {
academicTermId: round?.academicTermId ?? defaultAcademicTermId(terms.value),
name: round?.name ?? '',
startsAt: round ? toPickerValue(round.startsAt) : toPickerValue(now.toISOString()),
endsAt: round ? toPickerValue(round.endsAt) : toPickerValue(ends.toISOString()),
withdrawalEndsAt: round
? toPickerValue(round.withdrawalEndsAt)
: toPickerValue(withdrawal.toISOString()),
maxCredits: round?.maxCredits ?? 30,
maxCourseCount: round?.maxCourseCount ?? undefined,
eligibleGrades: [...(round?.eligibleGrades ?? [])],
notes: round?.notes ?? '',
})
roundDialog.value = true
}
async function saveRound() {
if (!roundForm.academicTermId || !roundForm.name?.trim()) {
ElMessage.warning('请选择学期并填写批次名称。')
return
}
try {
const payload = {
...roundForm,
maxCourseCount: roundForm.maxCourseCount || null,
eligibleGrades: roundForm.eligibleGrades ?? [],
startsAt: toIso(roundForm.startsAt),
endsAt: toIso(roundForm.endsAt),
withdrawalEndsAt: toIso(roundForm.withdrawalEndsAt),
}
if (editingRoundId.value) {
await http.put(`/course-selections/rounds/${editingRoundId.value}`, payload)
} else {
await http.post('/course-selections/rounds', payload)
}
roundDialog.value = false
ElMessage.success(editingRoundId.value ? '选课批次已更新' : '选课批次草稿已创建')
await loadRounds(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteRound(round: any) {
try {
await ElMessageBox.confirm(`删除选课批次“${round.name}”?`, '删除草稿', {
type: 'warning',
})
await http.delete(`/course-selections/rounds/${round.id}`)
await loadRounds(false)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function openSelection(round: any) {
try {
await ElMessageBox.confirm(
'开放后批次及教学班配置将锁定,学生可在设置的时间窗口内选课。',
'开放选课',
{ type: 'warning', confirmButtonText: '确认开放', cancelButtonText: '取消' },
)
await http.post(`/course-selections/rounds/${round.id}/open`)
ElMessage.success('选课批次已开放')
await loadRounds()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function closeSelection(round: any) {
try {
await ElMessageBox.confirm(
'关闭后学生将不能继续选课或退课,系统会向本轮参与学生发送最终选课结果。',
'关闭选课',
{ type: 'warning', confirmButtonText: '确认关闭', cancelButtonText: '取消' },
)
const { data } = await http.post(`/course-selections/rounds/${round.id}/close`)
ElMessage.success(`选课批次已关闭,已向 ${data.notifiedStudentCount} 名学生发送结果通知`)
await loadRounds()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openOffering(offering?: any) {
editingOfferingId.value = offering?.id ?? ''
Object.assign(offeringForm, {
teachingTaskId: offering?.teachingTaskId,
capacity: offering?.capacity ?? 60,
isOpenToAll: offering?.isOpenToAll ?? false,
notes: offering?.notes ?? '',
})
offeringDialog.value = true
}
function onTaskChanged(taskId: string) {
const task = tasks.value.find((item) => item.id === taskId)
if (task) offeringForm.capacity = task.capacity
}
async function saveOffering() {
if (!offeringForm.teachingTaskId) {
ElMessage.warning('请选择要进入选课的教学班。')
return
}
try {
const base = `/course-selections/rounds/${selectedRound.value.id}/offerings`
if (editingOfferingId.value) {
await http.put(`${base}/${editingOfferingId.value}`, offeringForm)
} else {
await http.post(base, offeringForm)
}
offeringDialog.value = false
ElMessage.success(editingOfferingId.value ? '教学班配置已更新' : '教学班已加入本轮选课')
await selectRound(selectedRound.value)
await loadRounds()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteOffering(offering: any) {
try {
await ElMessageBox.confirm(`从本轮移除“${offering.taskName}”?`, '移除教学班', {
type: 'warning',
})
await http.delete(
`/course-selections/rounds/${selectedRound.value.id}/offerings/${offering.id}`,
)
await selectRound(selectedRound.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function showRoster(offering: any) {
rosterDrawer.value = true
await loadRoster(offering.id)
}
async function loadRoster(offeringId: string) {
rosterLoading.value = true
try {
roster.value = (
await http.get(`/course-selections/offerings/${offeringId}/roster`)
).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
rosterLoading.value = false
}
}
async function openProxyEnrollment() {
studentKeyword.value = ''
selectedStudentIds.value = []
eligiblePage.value = 1
proxyDialog.value = true
await loadEligibleStudents()
}
async function loadEligibleStudents(page = eligiblePage.value) {
if (!roster.value) return
eligibleLoading.value = true
eligiblePage.value = page
try {
const { data } = await http.get(
`/course-selections/offerings/${roster.value.id}/eligible-students`,
{
params: {
keyword: studentKeyword.value.trim() || undefined,
page,
pageSize: 20,
},
},
)
eligibleStudents.value = data.items
eligibleTotal.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
eligibleLoading.value = false
}
}
function onEligibleSelectionChanged(rows: any[]) {
selectedStudentIds.value = rows.map((item) => item.id)
}
async function proxyEnroll() {
if (!roster.value || selectedStudentIds.value.length === 0) {
ElMessage.warning('请至少选择一名学生。')
return
}
proxySubmitting.value = true
try {
const { data } = await http.post(
`/course-selections/offerings/${roster.value.id}/admin-enrollments`,
{ studentIds: selectedStudentIds.value },
)
ElMessage.success(`已为 ${data.enrolledCount} 名学生完成代选`)
proxyDialog.value = false
await loadRoster(roster.value.id)
if (selectedRound.value) await selectRound(selectedRound.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
proxySubmitting.value = false
}
}
const forceDialog = ref(false)
const forceSubmitting = ref(false)
function openForceEnrollment() {
studentKeyword.value = ''
selectedStudentIds.value = []
eligiblePage.value = 1
forceDialog.value = true
loadForceEligibleStudents()
}
async function loadForceEligibleStudents(page = eligiblePage.value) {
if (!roster.value) return
eligibleLoading.value = true
eligiblePage.value = page
try {
const { data } = await http.get(
`/course-selections/offerings/${roster.value.id}/eligible-students`,
{
params: {
keyword: studentKeyword.value.trim() || undefined,
page,
pageSize: 20,
forceMode: true,
},
},
)
eligibleStudents.value = data.items
eligibleTotal.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
eligibleLoading.value = false
}
}
async function forceEnrollSubmit() {
if (!roster.value || selectedStudentIds.value.length === 0) {
ElMessage.warning('请至少选择一名学生。')
return
}
forceSubmitting.value = true
try {
const { data } = await http.post(
`/course-selections/offerings/${roster.value.id}/force-enroll`,
{ studentIds: selectedStudentIds.value },
)
ElMessage.success(`已强制选入 ${data.enrolledCount} 名学生(忽略所有限制)`)
forceDialog.value = false
await loadRoster(roster.value.id)
if (selectedRound.value) await selectRound(selectedRound.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
forceSubmitting.value = false
}
}
async function removeFromRoster(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}/admin-enrollments/${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 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', {
offeringId: offering.id,
})
ElMessage.success(`已选“${offering.courseName}”`)
await selectRound(selectedRound.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
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
? ''
: offering.id
}
async function withdraw(offering: any) {
const enrollment = enrollments.value.find(
(item) =>
item.courseSelectionOfferingId === offering.id &&
item.status === 'Enrolled',
)
if (!enrollment) return
try {
await ElMessageBox.confirm(
`确定退选“${offering.courseName}”吗?名额释放后可能被其他同学选择。`,
'确认退课',
{ type: 'warning', confirmButtonText: '确认退选', cancelButtonText: '暂不退选' },
)
await http.delete(`/course-selections/student/enrollments/${enrollment.id}`)
ElMessage.success('已退选')
await selectRound(selectedRound.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
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) {
const [termResponse, optionResponse] = await Promise.all([
http.get('/base-data/terms'),
canManageRounds.value
? http.get('/course-selections/configuration-options')
: Promise.resolve({ data: { grades: [] } }),
])
terms.value = termResponse.data
gradeOptions.value = optionResponse.data.grades
}
await loadRounds(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
})
</script>
<template>
<div class="page-stack selection-page">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE REGISTRATION</span>
<h2>{{ isStudent ? '学生选课' : '选课管理' }}</h2>
<p v-if="isStudent">在开放时间内安排本学期课程系统会实时校验容量学分与上课时间</p>
<p v-else>设置选课窗口投放教学班并以实时名单掌握教学班容量</p>
</div>
<el-button v-if="canManageRounds" type="primary" :icon="Plus" @click="openRound()">
新建选课批次
</el-button>
<el-button v-else :icon="Refresh" @click="loadRounds()">刷新名额</el-button>
</section>
<section v-if="rounds.length" class="selection-round-strip" v-loading="loading">
<button
v-for="round in rounds"
:key="round.id"
type="button"
:class="{
active: selectedRound?.id === round.id,
'historical-record': !round.termIsCurrent && !round.termIsArchived,
'archived-record': round.termIsArchived,
}"
@click="selectRound(round)"
>
<span>{{ round.termName }}</span>
<b>{{ round.name }}</b>
<small>{{ formatDateTime(round.startsAt) }} {{ formatDateTime(round.endsAt) }}</small>
<small>{{ roundLimitLabel(round) }}</small>
<i :class="round.status.toLowerCase()">{{ statusLabels[round.status] }}</i>
</button>
</section>
<template v-if="selectedRound">
<section class="selection-window">
<div class="window-seal">
<el-icon><Clock /></el-icon>
<span>{{ selectedRound.isAvailableNow ? '正在开放' : statusLabels[selectedRound.status] }}</span>
</div>
<div class="window-copy">
<span>SELECTION WINDOW · {{ selectedRound.termName }}</span>
<h3>{{ selectedRound.name }}</h3>
<p>
选课 {{ formatDateTime(selectedRound.startsAt) }}{{ formatDateTime(selectedRound.endsAt) }}
<em>退课截止 {{ formatDateTime(selectedRound.withdrawalEndsAt) }}</em>
<em>{{ roundLimitLabel(selectedRound) }}</em>
</p>
</div>
<div v-if="isStudent" class="credit-meter">
<div>
<span>已选学分</span>
<b>{{ selectedCredits }}</b>
<small>/ {{ selectedRound.maxCredits }}</small>
</div>
<div class="credit-track"><i :style="{ width: `${creditPercent}%` }" /></div>
<p>
{{ selectedCount }}{{ selectedRound.maxCourseCount ? ` / ${selectedRound.maxCourseCount}` : '' }} 门课程
· 剩余可选 {{ Math.max(0, selectedRound.maxCredits - selectedCredits) }} 学分
</p>
</div>
<div v-else class="round-actions">
<el-button
v-if="canManageRounds && selectedRound.status === 'Draft'"
@click="openRound(selectedRound)"
>编辑批次</el-button>
<el-button
v-if="canManageRounds && selectedRound.status === 'Draft'"
type="success"
@click="openSelection(selectedRound)"
>开放选课</el-button>
<el-button
v-if="canManageRounds && selectedRound.status === 'Open'"
type="warning"
@click="closeSelection(selectedRound)"
>关闭选课</el-button>
<el-button
v-if="canManageRounds && selectedRound.status === 'Draft'"
type="danger"
plain
@click="deleteRound(selectedRound)"
>删除草稿</el-button>
</div>
</section>
<section v-if="isManager" class="data-card" v-loading="detailLoading">
<div class="selection-ledger-head">
<div>
<span>OFFERING LEDGER</span>
<h3>本轮教学班</h3>
<p>{{ offerings.length }} 个教学班 · 开放后配置锁定名单随学生选退实时更新</p>
</div>
<el-button
v-if="selectedRound.status === 'Draft'"
type="primary"
:icon="Plus"
@click="openOffering()"
>加入教学班</el-button>
</div>
<el-table :data="offerings" class="data-table">
<el-table-column label="教学班 / 课程" min-width="260">
<template #default="{ row }">
<div class="course-name">
<b>{{ row.taskName }}</b>
<span>{{ row.taskNumber }} · {{ row.courseCode }} {{ row.courseName }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="教师 / 行政班" min-width="200">
<template #default="{ row }">
<div class="course-name">
<b>{{ row.teacherNames.join('、') || '未安排' }}</b>
<span>
{{ row.isOpenToAll ? '全校学生' : row.classNames.join('、') }}
· {{ roundGradeLabel(selectedRound) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="学分" width="75" prop="credits" />
<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">
<template #default="{ row }">
<el-button link type="primary" @click="showRoster(row)">名单管理</el-button>
<el-button
v-if="selectedRound.status === 'Draft'"
link
type="primary"
@click="openOffering(row)"
>编辑</el-button>
<el-button
v-if="selectedRound.status === 'Draft'"
link
type="danger"
@click="deleteOffering(row)"
>移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="本轮尚未加入教学班" /></template>
</el-table>
</section>
<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: 'Waitlisted' },
{ 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',
waitlisted: offering.enrollmentStatus === 'Waitlisted',
previewing: offering.id === previewOfferingId,
blocked: !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus)
&& offeringEligibilityReason(offering),
},
]"
>
<header>
<div>
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
<h3>{{ offering.courseName }}</h3>
<p>
{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分
<el-tag v-if="offering.isRetake && offering.enrollmentStatus !== 'Enrolled'" size="small" type="warning" effect="plain" style="margin-left:6px">重修</el-tag>
</p>
</div>
<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)">
<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>
</div>
<div
v-if="!['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && offeringBlockReason(offering)"
class="selection-block-reason"
>
{{ offeringBlockReason(offering) }}
</div>
<footer>
<div class="seat-meter">
<template v-if="offering.isRetake && offering.enrollmentStatus !== 'Enrolled'">
<span>剩余 {{ Math.max(0, Math.ceil(offering.capacity * 1.15) - offering.enrolledCount) }} / {{ Math.ceil(offering.capacity * 1.15) }} <em>(重修扩容)</em></span>
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / (Math.ceil(offering.capacity * 1.15)) * 100)}%` }" /></div>
</template>
<template v-else>
<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"
text
:type="offering.id === previewOfferingId ? 'warning' : 'primary'"
@click="togglePreview(offering)"
>{{ offering.id === previewOfferingId ? '取消试排' : '课表试排' }}</el-button>
<el-button
v-if="offering.enrollmentStatus === 'Enrolled'"
type="danger"
plain
: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(offeringEligibilityReason(offering))"
@click="enroll(offering)"
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
</footer>
</article>
<el-empty
v-if="!filteredOfferings.length"
:description="offerings.length ? '没有符合当前筛选条件的教学班' : '本轮没有适合你所在班级的课程'"
/>
</section>
</template>
</template>
<el-empty v-else-if="!loading" description="暂无选课批次" />
<el-dialog
v-model="roundDialog"
:title="editingRoundId ? '编辑选课批次' : '新建选课批次'"
width="720px"
>
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="开课学期" required>
<el-select v-model="roundForm.academicTermId">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select>
</el-form-item>
<el-form-item label="批次名称" required>
<el-input v-model="roundForm.name" placeholder="如 第一轮选课" />
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="选课开始时间" required>
<el-date-picker v-model="roundForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
<el-form-item label="选课结束时间" required>
<el-date-picker v-model="roundForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="退课截止时间" required>
<el-date-picker v-model="roundForm.withdrawalEndsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
<el-form-item label="本轮学分上限">
<el-input-number v-model="roundForm.maxCredits" :min="0.5" :max="99" :step="0.5" />
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="适用年级">
<el-select
v-model="roundForm.eligibleGrades"
multiple
collapse-tags
collapse-tags-tooltip
clearable
placeholder="不选择表示全部年级"
>
<el-option
v-for="grade in gradeOptions"
:key="grade"
:label="`${grade} 级`"
:value="grade"
/>
</el-select>
</el-form-item>
<el-form-item label="最多课程门数">
<el-input-number
v-model="roundForm.maxCourseCount"
:min="1"
:max="100"
placeholder="不限制"
/>
</el-form-item>
</div>
<el-alert
type="info"
:closable="false"
title="适用年级会同时限制学生可见批次、自主选课、候补、管理员代选和自动递补;留空表示全部年级。"
/>
<el-form-item label="说明">
<el-input v-model="roundForm.notes" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="roundDialog = false">取消</el-button>
<el-button type="primary" @click="saveRound">保存草稿</el-button>
</template>
</el-dialog>
<el-dialog
v-model="offeringDialog"
:title="editingOfferingId ? '编辑教学班配置' : '加入教学班'"
width="620px"
>
<el-form label-position="top">
<el-form-item label="已发布教学班" required>
<el-select
v-model="offeringForm.teachingTaskId"
filterable
@change="onTaskChanged"
>
<el-option
v-for="task in selectableTasks"
:key="task.id"
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}${task.schedulingMode === 'Flexible' ? '(非排时)' : ''}`"
:value="task.id"
/>
</el-select>
</el-form-item>
<div class="form-grid compact">
<el-form-item label="选课容量">
<el-input-number v-model="offeringForm.capacity" :min="1" />
</el-form-item>
<el-form-item label="选课对象">
<el-switch
v-model="offeringForm.isOpenToAll"
active-text="全校学生"
inactive-text="教学任务关联班级"
/>
</el-form-item>
</div>
<el-form-item label="说明">
<el-input v-model="offeringForm.notes" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="offeringDialog = false">取消</el-button>
<el-button type="primary" @click="saveOffering">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="rosterDrawer" title="教学班名单管理" size="720px">
<template v-if="roster">
<div class="roster-summary">
<el-icon><UserFilled /></el-icon>
<div>
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
<b>{{ roster.taskName }}</b>
<small>
正式 {{ roster.enrolledCount }} / {{ roster.capacity }}
<template v-if="roster.waitlistedCount"> · 候补 {{ roster.waitlistedCount }} </template>
</small>
</div>
<div class="roster-actions">
<el-button
v-if="roster.canProxyEnroll"
type="primary"
:icon="Plus"
@click="openProxyEnrollment"
>代选学生</el-button>
<el-button
v-if="isManager"
type="danger"
plain
:icon="Plus"
@click="openForceEnrollment"
>强制选课</el-button>
</div>
</div>
<el-alert
v-if="roster.canProxyEnroll"
class="roster-notice"
type="info"
:closable="false"
title="公共必修课支持校级教务代选;系统仍会校验年级范围、教学班容量、课程门数、学分上限、重复课程和课表冲突。"
/>
<el-alert
v-if="isManager"
class="roster-notice force-notice"
type="warning"
:closable="false"
title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。"
/>
<el-table v-loading="rosterLoading" :data="roster.students">
<el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" />
<el-table-column label="年级" width="80">
<template #default="{ row }">{{ row.grade }} </template>
</el-table-column>
<el-table-column prop="className" label="行政班" min-width="150" />
<el-table-column label="选课时间" min-width="130">
<template #default="{ row }">{{ formatDateTime(row.enrolledAt) }}</template>
</el-table-column>
<el-table-column v-if="roster.canProxyEnroll" label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeFromRoster(row)">移出</el-button>
</template>
</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 label="年级" width="80">
<template #default="{ row }">{{ row.grade }} </template>
</el-table-column>
<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>
<el-dialog v-model="proxyDialog" title="公共必修课代选" width="760px">
<template v-if="roster">
<div class="proxy-course-note">
<b>{{ roster.courseName }}</b>
<span>{{ roster.taskNumber }} · 当前 {{ roster.enrolledCount }} / {{ roster.capacity }} </span>
</div>
<div class="proxy-search">
<el-input
v-model="studentKeyword"
clearable
placeholder="输入学号、姓名或行政班"
@keyup.enter="loadEligibleStudents(1)"
@clear="loadEligibleStudents(1)"
/>
<el-button type="primary" @click="loadEligibleStudents(1)">查询学生</el-button>
</div>
<el-table
v-loading="eligibleLoading"
:data="eligibleStudents"
row-key="id"
height="360"
@selection-change="onEligibleSelectionChanged"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" />
<el-table-column label="年级" width="80">
<template #default="{ row }">{{ row.grade }} </template>
</el-table-column>
<el-table-column prop="className" label="行政班" min-width="150" />
<el-table-column prop="majorName" label="专业" min-width="150" />
<template #empty><el-empty description="没有可代选的在籍学生" /></template>
</el-table>
<el-pagination
v-if="eligibleTotal > 20"
class="proxy-pagination"
layout="prev, pager, next, total"
:current-page="eligiblePage"
:page-size="20"
:total="eligibleTotal"
@current-change="loadEligibleStudents"
/>
</template>
<template #footer>
<div class="proxy-dialog-footer">
<span class="proxy-selected-count">已选择 {{ selectedStudentIds.length }} </span>
<el-button @click="proxyDialog = false">取消</el-button>
<el-button
type="primary"
:loading="proxySubmitting"
:disabled="selectedStudentIds.length === 0"
@click="proxyEnroll"
>确认代选</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="forceDialog" title="强制选课(忽略所有限制)" width="760px">
<template v-if="roster">
<div class="proxy-course-note">
<b>{{ roster.courseName }}</b>
<span>{{ roster.taskNumber }} · 当前 {{ roster.enrolledCount }} / {{ roster.capacity }} </span>
</div>
<el-alert
class="roster-notice"
type="error"
:closable="false"
title="强制选课将忽略容量、时间冲突、学分上限、重复课程等全部限制,请谨慎操作。"
/>
<div class="proxy-search" style="margin-top:12px">
<el-input
v-model="studentKeyword"
clearable
:prefix-icon="Search"
placeholder="学号、姓名或班级"
@keyup.enter="loadForceEligibleStudents(1)"
@clear="loadForceEligibleStudents(1)"
/>
<el-button type="primary" @click="loadForceEligibleStudents(1)">查询学生</el-button>
</div>
<el-table
v-loading="eligibleLoading"
:data="eligibleStudents"
row-key="id"
height="360"
@selection-change="onEligibleSelectionChanged"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" />
<el-table-column label="年级" width="80">
<template #default="{ row }">{{ row.grade }} </template>
</el-table-column>
<el-table-column prop="className" label="行政班" min-width="150" />
<el-table-column prop="collegeName" label="学院" min-width="120" />
<template #empty><el-empty description="没有可强制选课的在籍学生" /></template>
</el-table>
<el-pagination
v-if="eligibleTotal > 20"
class="proxy-pagination"
layout="prev, pager, next, total"
:current-page="eligiblePage"
:page-size="20"
:total="eligibleTotal"
@current-change="loadForceEligibleStudents"
/>
</template>
<template #footer>
<div class="proxy-dialog-footer">
<span class="proxy-selected-count">已选择 {{ selectedStudentIds.length }} </span>
<el-button @click="forceDialog = false">取消</el-button>
<el-button
type="danger"
:loading="forceSubmitting"
:disabled="selectedStudentIds.length === 0"
@click="forceEnrollSubmit"
>确认强制选课</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<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>