This commit is contained in:
2026-07-25 11:46:39 +08:00 Unverified
parent 533ff6b9f6
commit fdca6a3edf
8 changed files with 603 additions and 25 deletions
+348 -11
View File
@@ -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>