选课
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Calendar,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Tickets,
|
||||
UserFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -43,6 +45,9 @@ const eligibleLoading = ref(false)
|
||||
const proxySubmitting = ref(false)
|
||||
const studentKeyword = ref('')
|
||||
const selectedStudentIds = ref<string[]>([])
|
||||
const offeringKeyword = ref('')
|
||||
const offeringStatus = ref('All')
|
||||
const previewOfferingId = ref('')
|
||||
const roundForm = reactive<Record<string, any>>({})
|
||||
const offeringForm = reactive<Record<string, any>>({})
|
||||
|
||||
@@ -69,6 +74,177 @@ const creditPercent = computed(() => {
|
||||
const selectedCount = computed(() =>
|
||||
offerings.value.filter((item) => item.enrollmentStatus === 'Enrolled').length,
|
||||
)
|
||||
const selectedOfferings = computed(() =>
|
||||
offerings.value.filter((item) => item.enrollmentStatus === 'Enrolled'),
|
||||
)
|
||||
const previewOffering = computed(() =>
|
||||
offerings.value.find((item) => item.id === previewOfferingId.value) ?? null,
|
||||
)
|
||||
const selectableTasks = computed(() => {
|
||||
const usedTaskIds = new Set(
|
||||
offerings.value
|
||||
.filter((item) => item.id !== editingOfferingId.value)
|
||||
.map((item) => item.teachingTaskId),
|
||||
)
|
||||
return tasks.value.filter((item) => !usedTaskIds.has(item.id))
|
||||
})
|
||||
|
||||
function includesWeek(schedule: any, week: number) {
|
||||
return schedule.weekPattern === 'All'
|
||||
|| schedule.weekPattern === 'Odd' && week % 2 === 1
|
||||
|| schedule.weekPattern === 'Even' && week % 2 === 0
|
||||
}
|
||||
|
||||
function schedulesOverlap(first: any, second: any) {
|
||||
if (first.dayOfWeek !== second.dayOfWeek) return false
|
||||
const periodsOverlap = first.startPeriod < second.startPeriod + second.periodCount
|
||||
&& second.startPeriod < first.startPeriod + first.periodCount
|
||||
if (!periodsOverlap) return false
|
||||
const startWeek = Math.max(first.startWeek, second.startWeek)
|
||||
const endWeek = Math.min(first.endWeek, second.endWeek)
|
||||
for (let week = startWeek; week <= endWeek; week += 1) {
|
||||
if (includesWeek(first, week) && includesWeek(second, week)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function conflictingSelectedCourses(offering: any) {
|
||||
if (offering.enrollmentStatus === 'Enrolled' || offering.isFlexible) return []
|
||||
return selectedOfferings.value
|
||||
.filter((selected) =>
|
||||
selected.id !== offering.id
|
||||
&& selected.schedules.some((existing: any) =>
|
||||
offering.schedules.some((candidate: any) =>
|
||||
schedulesOverlap(candidate, existing),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map((item) => item.courseName)
|
||||
}
|
||||
|
||||
function offeringBlockReason(offering: any) {
|
||||
if (offering.enrollmentStatus === 'Enrolled') return ''
|
||||
if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内'
|
||||
if (offering.enrolledCount >= offering.capacity) return '教学班名额已满'
|
||||
if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布'
|
||||
if (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('、')}”时间冲突` : ''
|
||||
}
|
||||
|
||||
const filteredOfferings = computed(() => {
|
||||
const keyword = offeringKeyword.value.trim().toLocaleLowerCase()
|
||||
return offerings.value.filter((offering) => {
|
||||
const matchesKeyword = !keyword || [
|
||||
offering.courseCode,
|
||||
offering.courseName,
|
||||
offering.taskNumber,
|
||||
...(offering.teacherNames ?? []),
|
||||
].some((value) => String(value ?? '').toLocaleLowerCase().includes(keyword))
|
||||
if (!matchesKeyword) return false
|
||||
if (offeringStatus.value === 'Selected') {
|
||||
return offering.enrollmentStatus === 'Enrolled'
|
||||
}
|
||||
const blocked = Boolean(offeringBlockReason(offering))
|
||||
if (offeringStatus.value === 'Selectable') {
|
||||
return offering.enrollmentStatus !== 'Enrolled' && !blocked
|
||||
}
|
||||
if (offeringStatus.value === 'Blocked') {
|
||||
return offering.enrollmentStatus !== 'Enrolled' && blocked
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const timetableOfferings = computed(() => {
|
||||
const result = [...selectedOfferings.value]
|
||||
if (previewOffering.value
|
||||
&& previewOffering.value.enrollmentStatus !== 'Enrolled') {
|
||||
result.push(previewOffering.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const timetablePeriodCount = computed(() => Math.max(
|
||||
12,
|
||||
...timetableOfferings.value.flatMap((offering) =>
|
||||
offering.schedules.map((schedule: any) =>
|
||||
schedule.startPeriod + schedule.periodCount - 1,
|
||||
),
|
||||
),
|
||||
))
|
||||
const timetablePeriods = computed(() =>
|
||||
Array.from({ length: timetablePeriodCount.value }, (_, index) => index + 1),
|
||||
)
|
||||
const timetableCells = computed(() =>
|
||||
timetablePeriods.value.flatMap((period) =>
|
||||
Array.from({ length: 7 }, (_, index) => ({
|
||||
key: `${index + 1}-${period}`,
|
||||
dayOfWeek: index + 1,
|
||||
period,
|
||||
})),
|
||||
),
|
||||
)
|
||||
const timetableGroups = computed(() => {
|
||||
const entriesByDay = new Map<number, any[]>()
|
||||
timetableOfferings.value.forEach((offering, offeringIndex) => {
|
||||
offering.schedules.forEach((schedule: any) => {
|
||||
const dayEntries = entriesByDay.get(schedule.dayOfWeek) ?? []
|
||||
dayEntries.push({
|
||||
...schedule,
|
||||
courseName: offering.courseName,
|
||||
taskNumber: offering.taskNumber,
|
||||
isPreview: offering.id === previewOfferingId.value,
|
||||
hasConflict: offering.id === previewOfferingId.value
|
||||
&& conflictingSelectedCourses(offering).length > 0,
|
||||
tone: offeringIndex % 5,
|
||||
})
|
||||
entriesByDay.set(schedule.dayOfWeek, dayEntries)
|
||||
})
|
||||
})
|
||||
const groups: any[] = []
|
||||
entriesByDay.forEach((entries, dayOfWeek) => {
|
||||
entries
|
||||
.sort((first, second) =>
|
||||
first.startPeriod - second.startPeriod
|
||||
|| first.periodCount - second.periodCount,
|
||||
)
|
||||
.forEach((entry) => {
|
||||
const endPeriod = entry.startPeriod + entry.periodCount
|
||||
const current = groups.at(-1)
|
||||
if (current
|
||||
&& current.dayOfWeek === dayOfWeek
|
||||
&& entry.startPeriod < current.endPeriod) {
|
||||
current.endPeriod = Math.max(current.endPeriod, endPeriod)
|
||||
current.periodCount = current.endPeriod - current.startPeriod
|
||||
current.entries.push(entry)
|
||||
return
|
||||
}
|
||||
groups.push({
|
||||
key: `${dayOfWeek}-${entry.startPeriod}-${groups.length}`,
|
||||
dayOfWeek,
|
||||
startPeriod: entry.startPeriod,
|
||||
endPeriod,
|
||||
periodCount: entry.periodCount,
|
||||
entries: [entry],
|
||||
})
|
||||
})
|
||||
})
|
||||
return groups
|
||||
})
|
||||
const flexibleTimetableOfferings = computed(() =>
|
||||
timetableOfferings.value.filter((offering) => offering.isFlexible),
|
||||
)
|
||||
const previewConflictNames = computed(() =>
|
||||
previewOffering.value
|
||||
? conflictingSelectedCourses(previewOffering.value)
|
||||
: [],
|
||||
)
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) return '—'
|
||||
@@ -121,6 +297,7 @@ async function loadRounds(keepSelection = true) {
|
||||
|
||||
async function selectRound(round: any) {
|
||||
selectedRound.value = round
|
||||
previewOfferingId.value = ''
|
||||
detailLoading.value = true
|
||||
try {
|
||||
if (isStudent.value) {
|
||||
@@ -146,15 +323,13 @@ async function selectRound(round: any) {
|
||||
}
|
||||
|
||||
async function loadTasks(academicTermId: string) {
|
||||
const { data } = await http.get('/teaching-tasks', {
|
||||
const { data } = await http.get('/teaching-tasks/options', {
|
||||
params: {
|
||||
academicTermId,
|
||||
status: 'Published',
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
},
|
||||
})
|
||||
tasks.value = data.items
|
||||
tasks.value = data
|
||||
}
|
||||
|
||||
function openRound(round?: any) {
|
||||
@@ -401,6 +576,12 @@ async function enroll(offering: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function togglePreview(offering: any) {
|
||||
previewOfferingId.value = previewOfferingId.value === offering.id
|
||||
? ''
|
||||
: offering.id
|
||||
}
|
||||
|
||||
async function withdraw(offering: any) {
|
||||
const enrollment = enrollments.value.find(
|
||||
(item) =>
|
||||
@@ -567,8 +748,148 @@ onMounted(async () => {
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section v-else class="offering-grid" v-loading="detailLoading">
|
||||
<article v-for="offering in offerings" :key="offering.id" class="offering-ticket">
|
||||
<template v-else>
|
||||
<section class="data-card selection-timetable-card" v-loading="detailLoading">
|
||||
<div class="selection-timetable-head">
|
||||
<div>
|
||||
<span>WEEKLY ARRANGEMENT</span>
|
||||
<h3>已选课程表</h3>
|
||||
<p>
|
||||
{{ selectedCount ? `已排入 ${selectedCount} 门课程` : '选择课程后将在此形成周课表' }}
|
||||
<template v-if="previewOffering">
|
||||
· 正在试排“{{ previewOffering.courseName }}”
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="previewOffering"
|
||||
plain
|
||||
@click="previewOfferingId = ''"
|
||||
>结束试排</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="previewConflictNames.length"
|
||||
class="selection-preview-alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="`试排课程与“${previewConflictNames.join('、')}”时间冲突,不能同时选择。`"
|
||||
/>
|
||||
<div class="selection-timetable-scroll">
|
||||
<div
|
||||
class="selection-timetable-grid"
|
||||
:style="{ gridTemplateRows: `42px repeat(${timetablePeriodCount}, 64px)` }"
|
||||
>
|
||||
<div class="timetable-corner">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
节次
|
||||
</div>
|
||||
<div
|
||||
v-for="day in 7"
|
||||
:key="`day-${day}`"
|
||||
class="timetable-day"
|
||||
:style="{ gridColumn: day + 1, gridRow: 1 }"
|
||||
>{{ weekdayLabels[day] }}</div>
|
||||
<div
|
||||
v-for="period in timetablePeriods"
|
||||
:key="`period-${period}`"
|
||||
class="timetable-period"
|
||||
:style="{ gridColumn: 1, gridRow: period + 1 }"
|
||||
>
|
||||
<b>{{ period }}</b>
|
||||
<span>第 {{ period }} 节</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="cell in timetableCells"
|
||||
:key="cell.key"
|
||||
class="timetable-cell"
|
||||
:style="{
|
||||
gridColumn: cell.dayOfWeek + 1,
|
||||
gridRow: cell.period + 1,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
v-for="group in timetableGroups"
|
||||
:key="group.key"
|
||||
:class="['timetable-course', { multiple: group.entries.length > 1 }]"
|
||||
:style="{
|
||||
gridColumn: group.dayOfWeek + 1,
|
||||
gridRow: `${group.startPeriod + 1} / span ${group.periodCount}`,
|
||||
}"
|
||||
>
|
||||
<article
|
||||
v-for="entry in group.entries"
|
||||
:key="`${entry.taskNumber}-${entry.startWeek}-${entry.weekPattern}`"
|
||||
:class="[
|
||||
`tone-${entry.tone}`,
|
||||
{ preview: entry.isPreview, conflict: entry.hasConflict },
|
||||
]"
|
||||
>
|
||||
<b>{{ entry.courseName }}</b>
|
||||
<span>
|
||||
{{ entry.startWeek }}—{{ entry.endWeek }} 周
|
||||
{{ patternLabels[entry.weekPattern] === '每周' ? '' : patternLabels[entry.weekPattern] }}
|
||||
</span>
|
||||
<small>{{ entry.classroomName }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="flexibleTimetableOfferings.length"
|
||||
class="flexible-course-row"
|
||||
>
|
||||
<b>非排时课程</b>
|
||||
<span
|
||||
v-for="offering in flexibleTimetableOfferings"
|
||||
:key="offering.id"
|
||||
:class="{ preview: offering.id === previewOfferingId }"
|
||||
>
|
||||
{{ offering.courseName }} · 不占固定节次与教室
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-card selection-catalog">
|
||||
<div class="selection-catalog-head">
|
||||
<div>
|
||||
<span>AVAILABLE CLASSES</span>
|
||||
<h3>本轮可选教学班</h3>
|
||||
<p>先试排再选课;冲突、容量和学分限制会提前显示,提交时服务端会再次校验。</p>
|
||||
</div>
|
||||
<b>{{ filteredOfferings.length }} / {{ offerings.length }}</b>
|
||||
</div>
|
||||
<div class="selection-catalog-filter">
|
||||
<el-input
|
||||
v-model="offeringKeyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="搜索课程代码、课程名称、教学班或教师"
|
||||
/>
|
||||
<el-segmented
|
||||
v-model="offeringStatus"
|
||||
:options="[
|
||||
{ label: '全部', value: 'All' },
|
||||
{ label: '可选', value: 'Selectable' },
|
||||
{ label: '已选', value: 'Selected' },
|
||||
{ label: '受限', value: 'Blocked' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="offering-grid" v-loading="detailLoading">
|
||||
<article
|
||||
v-for="offering in filteredOfferings"
|
||||
:key="offering.id"
|
||||
:class="[
|
||||
'offering-ticket',
|
||||
{
|
||||
selected: offering.enrollmentStatus === 'Enrolled',
|
||||
previewing: offering.id === previewOfferingId,
|
||||
blocked: offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
||||
@@ -589,11 +910,23 @@ onMounted(async () => {
|
||||
</span>
|
||||
<span v-else-if="!offering.schedules.length" class="schedule-missing">课表尚未发布</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering)"
|
||||
class="selection-block-reason"
|
||||
>
|
||||
{{ offeringBlockReason(offering) }}
|
||||
</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.schedules.length || offering.isFlexible"
|
||||
text
|
||||
:type="offering.id === previewOfferingId ? 'warning' : 'primary'"
|
||||
@click="togglePreview(offering)"
|
||||
>{{ offering.id === previewOfferingId ? '取消试排' : '课表试排' }}</el-button>
|
||||
<el-button
|
||||
v-if="offering.enrollmentStatus === 'Enrolled'"
|
||||
type="danger"
|
||||
@@ -604,13 +937,17 @@ onMounted(async () => {
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
:disabled="!selectedRound.isAvailableNow || offering.enrolledCount >= offering.capacity || (!offering.isFlexible && !offering.schedules.length)"
|
||||
:disabled="Boolean(offeringBlockReason(offering))"
|
||||
@click="enroll(offering)"
|
||||
>{{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
<el-empty v-if="!offerings.length" description="本轮没有适合你所在班级的课程" />
|
||||
</section>
|
||||
<el-empty
|
||||
v-if="!filteredOfferings.length"
|
||||
:description="offerings.length ? '没有符合当前筛选条件的教学班' : '本轮没有适合你所在班级的课程'"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<el-empty v-else-if="!loading" description="暂无选课批次" />
|
||||
@@ -670,9 +1007,9 @@ onMounted(async () => {
|
||||
@change="onTaskChanged"
|
||||
>
|
||||
<el-option
|
||||
v-for="task in tasks"
|
||||
v-for="task in selectableTasks"
|
||||
:key="task.id"
|
||||
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}`"
|
||||
:label="`${task.taskNumber} · ${task.courseCode} ${task.courseName}${task.schedulingMode === 'Flexible' ? '(非排时)' : ''}`"
|
||||
:value="task.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
Reference in New Issue
Block a user