选课批次:时间窗口、退课截止、学分上限、开放/关闭。

教学班投放:容量、班级范围、全校开放。
学生选退课:容量、重复课程、学分上限、课表冲突校验。
教学班实时名单。
校级、学院级、学生角色分级操作。
SQLite 本地增量升级及演示数据。
MySQL 正式 EF Core 迁移。
Vue 已编译进 wwwroot,单服务运行无需 npm run dev。
学生端和管理端均完成桌面、手机响应式检查。
This commit is contained in:
2026-07-24 15:17:26 +08:00 Unverified
parent bcc4d33bd5
commit 151a22554f
19 changed files with 3972 additions and 6 deletions
+1
View File
@@ -16,6 +16,7 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
+25 -3
View File
@@ -9,6 +9,7 @@ import {
Reading,
Tickets,
Calendar,
CircleCheck,
User,
UserFilled,
} from '@element-plus/icons-vue'
@@ -19,6 +20,16 @@ const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const mobileMenu = ref(false)
const workspaceLabel = computed(() => {
const roles = auth.user?.roles ?? []
if (roles.includes('SuperAdmin')) return '系统全域管理'
if (roles.includes('AcademicAdmin')) return '校级教务管理'
if (roles.includes('CollegeAdmin')) return '学院教务管理'
if (roles.includes('Counselor')) return '辅导员班级工作'
if (roles.includes('Teacher')) return '教师教学工作'
if (roles.includes('Student')) return '学生学业服务'
return '教务工作台'
})
const pageTitle = computed(() => {
const titles: Record<string, string> = {
@@ -29,6 +40,7 @@ const pageTitle = computed(() => {
curriculum: '培养方案',
'teaching-tasks': '教学任务',
schedules: '排课与课表',
'course-selections': '选课与教学班',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
@@ -57,7 +69,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
<div v-if="!collapsed" class="term-stamp">
<span>当前工作区</span>
<b>校级教务管理</b>
<b>{{ workspaceLabel }}</b>
</div>
<el-menu
@@ -71,7 +83,10 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><DataAnalysis /></el-icon>
<template #title>教务总览</template>
</el-menu-item>
<el-menu-item index="/base-data">
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role))"
index="/base-data"
>
<el-icon><OfficeBuilding /></el-icon>
<template #title>基础数据</template>
</el-menu-item>
@@ -107,6 +122,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><Calendar /></el-icon>
<template #title>排课与课表</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'].includes(role))"
index="/course-selections"
>
<el-icon><CircleCheck /></el-icon>
<template #title>{{ auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '选课管理' : '学生选课' }}</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
@@ -115,7 +137,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 核心可用版</span>
<p>主数据培养方案与教学运行已就绪</p>
<p>教学运行排课与学生选课已就绪</p>
</div>
</aside>
+18
View File
@@ -25,6 +25,7 @@ const router = createRouter({
path: 'base-data',
name: 'base-data',
component: () => import('../views/BaseDataView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
},
{
path: 'personnel',
@@ -57,6 +58,14 @@ const router = createRouter({
component: () => import('../views/SchedulesView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
},
{
path: 'course-selections',
name: 'course-selections',
component: () => import('../views/CourseSelectionView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
},
},
{
path: 'users',
name: 'users',
@@ -75,6 +84,15 @@ router.beforeEach((to) => {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' }
if (
to.name === 'dashboard' &&
auth.user?.roles.includes('Student') &&
!auth.user.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
)
) {
return { name: 'course-selections' }
}
const roles = to.meta.roles as string[] | undefined
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
return { name: 'dashboard' }
+151
View File
@@ -249,6 +249,135 @@ button { cursor: pointer; }
.schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; }
.schedule-card > button:hover { color: white; }
.selection-page { min-width: 0; }
.selection-round-strip {
min-height: 104px; padding: 10px; display: flex; gap: 9px; overflow-x: auto;
border: 1px solid var(--line); background: white;
}
.selection-round-strip > button {
flex: 0 0 285px; min-height: 82px; padding: 13px 15px; display: grid;
grid-template-columns: 1fr auto; gap: 5px 12px; text-align: left;
border: 1px solid var(--line); border-left: 3px solid #bdc3ce;
color: var(--ink); background: #fafbfc;
}
.selection-round-strip > button:hover { background: white; }
.selection-round-strip > button.active {
border-color: var(--indigo); border-left-color: #48c8b7;
color: white; background: linear-gradient(115deg, #1b3067, #294584);
}
.selection-round-strip span, .selection-round-strip small {
overflow: hidden; color: #868f9e; font-size: 9px; white-space: nowrap; text-overflow: ellipsis;
}
.selection-round-strip b {
grid-column: 1 / -1; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif;
font-size: 14px; white-space: nowrap; text-overflow: ellipsis;
}
.selection-round-strip i {
align-self: start; padding: 3px 7px; border-radius: 10px;
color: #6d7583; background: #edf0f3; font-size: 9px; font-style: normal;
}
.selection-round-strip i.open { color: var(--teal); background: #e5f4f1; }
.selection-round-strip > button.active span,
.selection-round-strip > button.active small { color: #c6d0e8; }
.selection-round-strip > button.active i { color: white; background: rgba(255,255,255,.14); }
.selection-window {
min-height: 142px; display: grid; grid-template-columns: 105px minmax(300px, 1fr) auto;
align-items: stretch; color: white;
background:
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px),
linear-gradient(112deg, #162858, #243d7d 70%, #176b70);
background-size: 28px 28px, 28px 28px, auto;
overflow: hidden;
}
.window-seal {
padding: 20px 12px; display: grid; place-content: center; justify-items: center; gap: 9px;
border-right: 1px solid rgba(255,255,255,.13); color: #57d4c2;
}
.window-seal .el-icon { font-size: 26px; }
.window-seal span { font-size: 10px; font-weight: 700; letter-spacing: .08em; }
.window-copy { padding: 24px 27px; align-self: center; min-width: 0; }
.window-copy > span { color: #5ed4c4; font: 700 9px/1 Consolas, monospace; letter-spacing: .15em; }
.window-copy h3 {
margin: 9px 0 8px; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif;
font-size: 22px; font-weight: 500; letter-spacing: .03em; white-space: nowrap; text-overflow: ellipsis;
}
.window-copy p { margin: 0; color: #c4cee8; font-size: 10px; }
.window-copy em {
margin-left: 13px; padding-left: 13px; border-left: 1px solid rgba(255,255,255,.2);
color: #edcf9a; font-style: normal;
}
.credit-meter {
width: 310px; padding: 22px 28px; align-self: center;
border-left: 1px solid rgba(255,255,255,.13);
}
.credit-meter > div:first-child { display: flex; align-items: baseline; gap: 7px; }
.credit-meter span, .credit-meter small { color: #c2cce5; font-size: 10px; }
.credit-meter b { margin-left: auto; color: #5bd4c3; font: 700 34px/1 Consolas, monospace; }
.credit-track { height: 5px; margin-top: 12px; overflow: hidden; background: rgba(255,255,255,.15); }
.credit-track i { height: 100%; display: block; background: linear-gradient(90deg, #49c9b7, #e3ad54); }
.credit-meter p { margin: 9px 0 0; color: #aeb9d8; font-size: 9px; }
.round-actions {
width: 285px; padding: 23px; display: flex; flex-wrap: wrap; align-content: center;
justify-content: flex-end; gap: 8px; border-left: 1px solid rgba(255,255,255,.13);
}
.round-actions .el-button + .el-button { margin-left: 0; }
.selection-ledger-head {
min-height: 95px; padding: 18px 21px; display: flex; align-items: center;
justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line);
}
.selection-ledger-head span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .14em; }
.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; }
.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 > header {
min-height: 99px; padding: 19px 20px; display: flex; justify-content: space-between;
gap: 16px; border-bottom: 1px dashed #d7dce4; position: relative;
}
.offering-ticket > header::before,
.offering-ticket > header::after {
content: ""; position: absolute; bottom: -7px; width: 12px; height: 12px;
border-radius: 50%; background: var(--soft);
}
.offering-ticket > header::before { left: -7px; }
.offering-ticket > header::after { right: -7px; }
.offering-ticket header span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .08em; }
.offering-ticket h3 {
margin: 8px 0 6px; font-family: "STZhongsong", "Songti SC", serif;
font-size: 20px; font-weight: 600;
}
.offering-ticket header p { margin: 0; color: var(--muted); font-size: 11px; }
.selected-mark {
flex: 0 0 auto; height: fit-content; padding: 5px 9px; display: flex; align-items: center; gap: 4px;
border-radius: 13px; color: var(--teal); background: #e8f5f2; font-size: 10px; font-style: normal;
}
.ticket-schedules { min-height: 85px; padding: 14px 20px; display: grid; align-content: center; gap: 8px; }
.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; }
.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;
}
.seat-meter { flex: 1; min-width: 0; }
.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); }
.roster-summary {
margin: 0 0 18px; padding: 16px; display: flex; align-items: center; gap: 14px;
color: white; background: linear-gradient(115deg, #1b3067, #294584);
}
.roster-summary > .el-icon { font-size: 28px; color: #5bd4c3; }
.roster-summary span, .roster-summary b, .roster-summary small { display: block; }
.roster-summary span { color: #59d2c1; font: 700 9px Consolas, monospace; }
.roster-summary b { margin-top: 5px; font-size: 14px; }
.roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
@@ -339,6 +468,13 @@ button { cursor: pointer; }
.schedule-search { flex-wrap: wrap; }
.schedule-search .el-input { width: 100%; }
.schedule-search > span { width: 100%; margin-left: 0; }
.selection-window { grid-template-columns: 82px 1fr; }
.credit-meter, .round-actions {
grid-column: 1 / -1; width: auto; border-top: 1px solid rgba(255,255,255,.13);
border-left: none;
}
.round-actions { justify-content: flex-start; }
.offering-grid { grid-template-columns: 1fr; }
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; }
@@ -350,6 +486,21 @@ button { cursor: pointer; }
.login-panel { padding: 32px 22px; background: white; }
}
@media (max-width: 600px) {
.selection-round-strip > button { flex-basis: 240px; }
.selection-window { display: block; }
.window-seal { min-height: 54px; padding: 10px; display: flex; border-right: none; border-bottom: 1px solid rgba(255,255,255,.13); }
.window-seal .el-icon { font-size: 18px; }
.window-copy { padding: 20px 18px; }
.window-copy h3 { white-space: normal; }
.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; }
.offering-ticket > footer { align-items: stretch; flex-direction: column; }
.offering-ticket > footer .el-button { width: 100%; }
.el-drawer { width: 100% !important; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}
+628
View File
@@ -0,0 +1,628 @@
<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>
+14 -2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue'
import http from '../api/http'
import { useAuthStore } from '../stores/auth'
interface DashboardData {
currentTerm?: { name: string; startDate: string; endDate: string }
@@ -10,6 +11,7 @@ interface DashboardData {
}
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const data = ref<DashboardData>({ counts: {} })
@@ -40,7 +42,11 @@ onMounted(async () => {
</p>
<p v-else>请先在基础数据中建立学期档案</p>
</div>
<button type="button" @click="router.push('/base-data')">
<button
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role))"
type="button"
@click="router.push('/base-data')"
>
<span>维护学期与组织数据</span>
<b></b>
</button>
@@ -101,18 +107,24 @@ onMounted(async () => {
<b>{{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布</b>
<i class="done">已建立</i>
</div>
<div>
<span>学生选课</span>
<b>{{ data.counts.courseSelectionRounds ?? 0 }} 个批次 · {{ data.counts.courseEnrollments ?? 0 }} 条有效选课</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>排课与课表发布链路已就绪下一步进入学生选课轮次容量与候补管理</p>
<p>选课批次容量校验和教学班名单已就绪下一步进入成绩录入审核与学业档案</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
<span class="active">培养方案</span>
<span class="active">排课课表</span>
<span class="active">学生选课</span>
</div>
</article>
</section>