已把选课的“年级划分”和限制规则补成完整闭环:

选课批次可指定多个适用年级;留空兼容原有“全部年级”。
新增可选的“最多课程门数”,与原有学分上限并行。
年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。
普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。
管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。
新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
This commit is contained in:
2026-07-26 16:12:21 +08:00 Unverified
parent 56b13b3adb
commit f62cd21319
11 changed files with 4920 additions and 25 deletions
+86 -4
View File
@@ -24,6 +24,7 @@ const canManageRounds = computed(() =>
)
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[]>([])
@@ -130,6 +131,10 @@ function offeringEligibilityReason(offering: any) {
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} 学分上限`
}
@@ -302,6 +307,18 @@ function formatDateTime(value: string) {
}).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)
@@ -395,6 +412,8 @@ function openRound(round?: any) {
? toPickerValue(round.withdrawalEndsAt)
: toPickerValue(withdrawal.toISOString()),
maxCredits: round?.maxCredits ?? 30,
maxCourseCount: round?.maxCourseCount ?? undefined,
eligibleGrades: [...(round?.eligibleGrades ?? [])],
notes: round?.notes ?? '',
})
roundDialog.value = true
@@ -408,6 +427,8 @@ async function saveRound() {
try {
const payload = {
...roundForm,
maxCourseCount: roundForm.maxCourseCount || null,
eligibleGrades: roundForm.eligibleGrades ?? [],
startsAt: toIso(roundForm.startsAt),
endsAt: toIso(roundForm.endsAt),
withdrawalEndsAt: toIso(roundForm.withdrawalEndsAt),
@@ -763,7 +784,16 @@ async function cancelWaitlist(offering: any) {
onMounted(async () => {
try {
if (isManager.value) terms.value = (await http.get('/base-data/terms')).data
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))
@@ -801,6 +831,7 @@ onMounted(async () => {
<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>
@@ -817,6 +848,7 @@ onMounted(async () => {
<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">
@@ -826,7 +858,10 @@ onMounted(async () => {
<small>/ {{ selectedRound.maxCredits }}</small>
</div>
<div class="credit-track"><i :style="{ width: `${creditPercent}%` }" /></div>
<p>{{ selectedCount }} 门课程 · 剩余可选 {{ Math.max(0, selectedRound.maxCredits - selectedCredits) }} 学分</p>
<p>
{{ selectedCount }}{{ selectedRound.maxCourseCount ? ` / ${selectedRound.maxCourseCount}` : '' }} 门课程
· 剩余可选 {{ Math.max(0, selectedRound.maxCredits - selectedCredits) }} 学分
</p>
</div>
<div v-else class="round-actions">
<el-button
@@ -879,7 +914,10 @@ onMounted(async () => {
<template #default="{ row }">
<div class="course-name">
<b>{{ row.teacherNames.join('、') || '未安排' }}</b>
<span>{{ row.isOpenToAll ? '全校开放' : row.classNames.join('、') }}</span>
<span>
{{ row.isOpenToAll ? '全校学生' : row.classNames.join('、') }}
· {{ roundGradeLabel(selectedRound) }}
</span>
</div>
</template>
</el-table-column>
@@ -1181,6 +1219,38 @@ onMounted(async () => {
<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>
@@ -1266,7 +1336,7 @@ onMounted(async () => {
class="roster-notice"
type="info"
:closable="false"
title="公共必修课支持校级教务代选;系统仍会校验教学班容量、学分上限、重复课程和课表冲突。"
title="公共必修课支持校级教务代选;系统仍会校验年级范围、教学班容量、课程门数、学分上限、重复课程和课表冲突。"
/>
<el-alert
v-if="isManager"
@@ -1278,6 +1348,9 @@ onMounted(async () => {
<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>
@@ -1301,6 +1374,9 @@ onMounted(async () => {
<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>
@@ -1347,6 +1423,9 @@ onMounted(async () => {
<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>
@@ -1408,6 +1487,9 @@ onMounted(async () => {
<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>