选课批次:时间窗口、退课截止、学分上限、开放/关闭。
教学班投放:容量、班级范围、全校开放。 学生选退课:容量、重复课程、学分上限、课表冲突校验。 教学班实时名单。 校级、学院级、学生角色分级操作。 SQLite 本地增量升级及演示数据。 MySQL 正式 EF Core 迁移。 Vue 已编译进 wwwroot,单服务运行无需 npm run dev。 学生端和管理端均完成桌面、手机响应式检查。
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user