Files
Academic-Affairs-System/web/src/views/CourseSelectionView.vue
T
biss 151a22554f 选课批次:时间窗口、退课截止、学分上限、开放/关闭。
教学班投放:容量、班级范围、全校开放。
学生选退课:容量、重复课程、学分上限、课表冲突校验。
教学班实时名单。
校级、学院级、学生角色分级操作。
SQLite 本地增量升级及演示数据。
MySQL 正式 EF Core 迁移。
Vue 已编译进 wwwroot,单服务运行无需 npm run dev。
学生端和管理端均完成桌面、手机响应式检查。
2026-07-24 15:17:26 +08:00

629 lines
23 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 {
CircleCheck,
Clock,
Plus,
Refresh,
Tickets,
UserFilled,
} from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
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 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 editingRoundId = ref('')
const editingOfferingId = ref('')
const roster = ref<any | null>(null)
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,
)
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 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)
?? 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
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', {
params: {
academicTermId,
status: 'Published',
page: 1,
pageSize: 100,
},
})
tasks.value = data.items
}
function openRound(round?: any) {
editingRoundId.value = round?.id ?? ''
const currentTerm = terms.value.find((item) => item.isCurrent)
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 ?? currentTerm?.id,
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,
notes: round?.notes ?? '',
})
roundDialog.value = true
}
async function saveRound() {
if (!roundForm.academicTermId || !roundForm.name?.trim()) {
ElMessage.warning('请选择学期并填写批次名称。')
return
}
try {
const payload = {
...roundForm,
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: '取消' },
)
await http.post(`/course-selections/rounds/${round.id}/close`)
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) {
try {
roster.value = (
await http.get(`/course-selections/offerings/${offering.id}/roster`)
).data
rosterDrawer.value = true
} catch (error) {
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 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))
}
}
onMounted(async () => {
try {
if (isManager.value) terms.value = (await http.get('/base-data/terms')).data
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 }"
@click="selectRound(round)"
>
<span>{{ round.termName }}</span>
<b>{{ round.name }}</b>
<small>{{ formatDateTime(round.startsAt) }} {{ formatDateTime(round.endsAt) }}</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>
</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 }} 门课程 · 剩余可选 {{ 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('、') }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="学分" width="75" prop="credits" />
<el-table-column label="名单 / 容量" width="125">
<template #default="{ row }">
<b class="capacity-number">{{ row.enrolledCount }} / {{ row.capacity }}</b>
</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>
<section v-else class="offering-grid" v-loading="detailLoading">
<article v-for="offering in offerings" :key="offering.id" class="offering-ticket">
<header>
<div>
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
<h3>{{ offering.courseName }}</h3>
<p>{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分</p>
</div>
<i v-if="offering.enrollmentStatus === 'Enrolled'" class="selected-mark">
<el-icon><CircleCheck /></el-icon> 已选
</i>
</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.schedules.length" class="schedule-missing">课表尚未发布</span>
</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.enrollmentStatus === 'Enrolled'"
type="danger"
plain
:disabled="selectedRound.status !== 'Open'"
@click="withdraw(offering)"
>退选</el-button>
<el-button
v-else
type="primary"
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || !offering.schedules.length"
@click="enroll(offering)"
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
</footer>
</article>
<el-empty v-if="!offerings.length" description="本轮没有适合你所在班级的课程" />
</section>
</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="term.name" :value="term.id" />
</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>
<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 tasks"
:key="task.id"
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}`"
: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="620px">
<template v-if="roster">
<div class="roster-summary">
<el-icon><UserFilled /></el-icon>
<div>
<span>{{ roster.taskNumber }}</span>
<b>{{ roster.taskName }}</b>
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} </small>
</div>
</div>
<el-table :data="roster.students">
<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.enrolledAt) }}</template>
</el-table-column>
<template #empty><el-empty description="暂无学生选课" /></template>
</el-table>
</template>
</el-drawer>
</div>
</template>