已把选课的“年级划分”和限制规则补成完整闭环:
选课批次可指定多个适用年级;留空兼容原有“全部年级”。 新增可选的“最多课程门数”,与原有学分上限并行。 年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。 普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。 管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。 新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user