选课
This commit is contained in:
@@ -125,16 +125,44 @@ function conflictingSelectedCourses(offering: any) {
|
||||
function offeringBlockReason(offering: any) {
|
||||
if (offering.enrollmentStatus === 'Enrolled') return ''
|
||||
if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内'
|
||||
if (offering.enrolledCount >= offering.capacity) return '教学班名额已满'
|
||||
const effectiveCap = offering.isRetake
|
||||
? Math.ceil(offering.capacity * 1.15)
|
||||
: offering.capacity
|
||||
if (offering.enrolledCount >= effectiveCap) return '教学班名额已满'
|
||||
if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布'
|
||||
if (selectedOfferings.value.some((item) =>
|
||||
if (!offering.isRetake && selectedOfferings.value.some((item) =>
|
||||
item.id !== offering.id && item.courseCode === offering.courseCode,
|
||||
)) return '本学期已选择同一课程'
|
||||
if (selectedCredits.value + Number(offering.credits) > Number(selectedRound.value.maxCredits)) {
|
||||
return `选择后将超过 ${selectedRound.value.maxCredits} 学分上限`
|
||||
}
|
||||
const conflicts = conflictingSelectedCourses(offering)
|
||||
return conflicts.length ? `与已选“${conflicts.join('、')}”时间冲突` : ''
|
||||
if (conflicts.length) {
|
||||
if (!offering.isRetake) return `与已选”${conflicts.join('、')}”时间冲突`
|
||||
// Retake: calculate overlap (client-side rough estimate)
|
||||
if (!calcRetakeOverlapOk(offering)) return `重修时间冲突超过 50%,无法选课`
|
||||
return '' // retake with acceptable overlap
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function calcRetakeOverlapOk(offering: any): boolean {
|
||||
let totalPeriods = 0, overlapPeriods = 0
|
||||
for (const candidate of offering.schedules) {
|
||||
totalPeriods += candidate.periodCount
|
||||
for (const selected of selectedOfferings.value) {
|
||||
for (const existing of (selected.schedules ?? [])) {
|
||||
if (candidate.dayOfWeek !== existing.dayOfWeek) continue
|
||||
const overlapStart = Math.max(candidate.startPeriod, existing.startPeriod)
|
||||
const overlapEnd = Math.min(
|
||||
candidate.startPeriod + candidate.periodCount,
|
||||
existing.startPeriod + existing.periodCount,
|
||||
)
|
||||
if (overlapEnd > overlapStart) overlapPeriods += overlapEnd - overlapStart
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalPeriods === 0 || (overlapPeriods / totalPeriods * 100) <= 50
|
||||
}
|
||||
|
||||
const filteredOfferings = computed(() => {
|
||||
@@ -545,6 +573,63 @@ async function proxyEnroll() {
|
||||
}
|
||||
}
|
||||
|
||||
const forceDialog = ref(false)
|
||||
const forceSubmitting = ref(false)
|
||||
|
||||
function openForceEnrollment() {
|
||||
studentKeyword.value = ''
|
||||
selectedStudentIds.value = []
|
||||
eligiblePage.value = 1
|
||||
forceDialog.value = true
|
||||
loadForceEligibleStudents()
|
||||
}
|
||||
|
||||
async function loadForceEligibleStudents(page = eligiblePage.value) {
|
||||
if (!roster.value) return
|
||||
eligibleLoading.value = true
|
||||
eligiblePage.value = page
|
||||
try {
|
||||
const { data } = await http.get(
|
||||
`/course-selections/offerings/${roster.value.id}/eligible-students`,
|
||||
{
|
||||
params: {
|
||||
keyword: studentKeyword.value.trim() || undefined,
|
||||
page,
|
||||
pageSize: 20,
|
||||
},
|
||||
},
|
||||
)
|
||||
eligibleStudents.value = data.items
|
||||
eligibleTotal.value = data.total
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
eligibleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function forceEnrollSubmit() {
|
||||
if (!roster.value || selectedStudentIds.value.length === 0) {
|
||||
ElMessage.warning('请至少选择一名学生。')
|
||||
return
|
||||
}
|
||||
forceSubmitting.value = true
|
||||
try {
|
||||
const { data } = await http.post(
|
||||
`/course-selections/offerings/${roster.value.id}/force-enroll`,
|
||||
{ studentIds: selectedStudentIds.value },
|
||||
)
|
||||
ElMessage.success(`已强制选入 ${data.enrolledCount} 名学生(忽略所有限制)`)
|
||||
forceDialog.value = false
|
||||
await loadRoster(roster.value.id)
|
||||
if (selectedRound.value) await selectRound(selectedRound.value)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
forceSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFromRoster(student: any) {
|
||||
if (!roster.value) return
|
||||
try {
|
||||
@@ -894,7 +979,10 @@ onMounted(async () => {
|
||||
<div>
|
||||
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
||||
<h3>{{ offering.courseName }}</h3>
|
||||
<p>{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分</p>
|
||||
<p>
|
||||
{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分
|
||||
<el-tag v-if="offering.isRetake && offering.enrollmentStatus !== 'Enrolled'" size="small" type="warning" effect="plain" style="margin-left:6px">重修</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
<i v-if="offering.enrollmentStatus === 'Enrolled'" class="selected-mark">
|
||||
<el-icon><CircleCheck /></el-icon> 已选
|
||||
@@ -918,8 +1006,14 @@ onMounted(async () => {
|
||||
</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>
|
||||
<template v-if="offering.isRetake && offering.enrollmentStatus !== 'Enrolled'">
|
||||
<span>剩余 {{ Math.max(0, Math.ceil(offering.capacity * 1.15) - offering.enrolledCount) }} / {{ Math.ceil(offering.capacity * 1.15) }} 席 <em>(重修扩容)</em></span>
|
||||
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / (Math.ceil(offering.capacity * 1.15)) * 100)}%` }" /></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>剩余 {{ Math.max(0, offering.capacity - offering.enrolledCount) }} / {{ offering.capacity }} 席</span>
|
||||
<div><i :style="{ width: `${Math.min(100, offering.enrolledCount / offering.capacity * 100)}%` }" /></div>
|
||||
</template>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="offering.schedules.length || offering.isFlexible"
|
||||
@@ -1045,12 +1139,20 @@ onMounted(async () => {
|
||||
<b>{{ roster.taskName }}</b>
|
||||
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} 人</small>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="roster.canProxyEnroll"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="openProxyEnrollment"
|
||||
>代选学生</el-button>
|
||||
<div class="roster-actions">
|
||||
<el-button
|
||||
v-if="roster.canProxyEnroll"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="openProxyEnrollment"
|
||||
>代选学生</el-button>
|
||||
<el-button
|
||||
v-if="isManager"
|
||||
type="warning"
|
||||
:icon="Plus"
|
||||
@click="openForceEnrollment"
|
||||
>强制选课</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="roster.canProxyEnroll"
|
||||
@@ -1059,6 +1161,13 @@ onMounted(async () => {
|
||||
:closable="false"
|
||||
title="公共必修课支持校级教务代选;系统仍会校验教学班容量、学分上限、重复课程和课表冲突。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="isManager"
|
||||
class="roster-notice force-notice"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。"
|
||||
/>
|
||||
<el-table v-loading="rosterLoading" :data="roster.students">
|
||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||
<el-table-column prop="name" label="姓名" width="90" />
|
||||
@@ -1129,5 +1238,71 @@ onMounted(async () => {
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="forceDialog" title="强制选课(忽略所有限制)" width="760px">
|
||||
<template v-if="roster">
|
||||
<div class="proxy-course-note">
|
||||
<b>{{ roster.courseName }}</b>
|
||||
<span>{{ roster.taskNumber }} · 当前 {{ roster.enrolledCount }} / {{ roster.capacity }} 人</span>
|
||||
</div>
|
||||
<el-alert
|
||||
class="roster-notice"
|
||||
type="error"
|
||||
:closable="false"
|
||||
title="强制选课将忽略容量、时间冲突、学分上限、重复课程等全部限制,请谨慎操作。"
|
||||
/>
|
||||
<div class="proxy-search" style="margin-top:12px">
|
||||
<el-input
|
||||
v-model="studentKeyword"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="学号、姓名或班级"
|
||||
@keyup.enter="loadForceEligibleStudents(1)"
|
||||
@clear="loadForceEligibleStudents(1)"
|
||||
/>
|
||||
<el-button type="primary" @click="loadForceEligibleStudents(1)">查询学生</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="eligibleLoading"
|
||||
:data="eligibleStudents"
|
||||
row-key="id"
|
||||
height="360"
|
||||
@selection-change="onEligibleSelectionChanged"
|
||||
>
|
||||
<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 prop="className" label="行政班" min-width="150" />
|
||||
<el-table-column prop="collegeName" label="学院" min-width="120" />
|
||||
<template #empty><el-empty description="没有可强制选课的在籍学生" /></template>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="eligibleTotal > 20"
|
||||
class="proxy-pagination"
|
||||
layout="prev, pager, next, total"
|
||||
:current-page="eligiblePage"
|
||||
:page-size="20"
|
||||
:total="eligibleTotal"
|
||||
@current-change="loadForceEligibleStudents"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="proxy-dialog-footer">
|
||||
<span class="proxy-selected-count">已选择 {{ selectedStudentIds.length }} 人</span>
|
||||
<el-button @click="forceDialog = false">取消</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:loading="forceSubmitting"
|
||||
:disabled="selectedStudentIds.length === 0"
|
||||
@click="forceEnrollSubmit"
|
||||
>确认强制选课</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.roster-actions { display: flex; gap: 8px; }
|
||||
.force-notice { margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -504,14 +504,14 @@ onMounted(async () => {
|
||||
<span v-else>{{ row.finalScore ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总评" width="110">
|
||||
<el-table-column label="总评" width="90">
|
||||
<template #default="{ row }">
|
||||
<div class="total-score-cell">
|
||||
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
||||
<span v-if="detail.canEdit && row.examStatus === 'Normal'" class="preview-score" :class="scoreClass(calcPreviewTotal(row))">
|
||||
参考 {{ calcPreviewTotal(row) ?? '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="detail.canEdit && row.examStatus === 'Normal'">
|
||||
<b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))">
|
||||
{{ calcPreviewTotal(row) ?? '—' }}
|
||||
</b>
|
||||
</template>
|
||||
<b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="考试状态" width="115">
|
||||
@@ -631,11 +631,13 @@ onMounted(async () => {
|
||||
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
|
||||
.add-item-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Preview score */
|
||||
.total-score-cell { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.preview-score { font-size: 10px; opacity: .7; white-space: nowrap; }
|
||||
.preview-score.failed { color: #b34e48; }
|
||||
.preview-score.excellent { color: #2d8975; }
|
||||
/* Preview total score */
|
||||
.total-score.preview {
|
||||
text-decoration: underline dashed;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--muted);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* College review */
|
||||
.college-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user