Files
Academic-Affairs-System/web/src/views/TimetableView.vue
T
biss fe508054d0 教学任务按 授课周数 × 周学时 = 课程总学时 双端校验,不匹配会显示具体差额并阻止保存;公共课批量生成同样校验。
排课约束增加课程、教师、学院、授课方式、场地要求、约束状态筛选,并支持批量修改当前筛选结果。
新增“非排时课程”授课方式:不进入自动排课
不占星期、节次和教室
不阻塞课表发布
允许正常选课
在班级课表和学生个人课表中单独展示
2026-07-25 08:18:25 +08:00

269 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
const route = useRoute()
const isMine = computed(() => route.meta.mine === true)
const isPublic = computed(() => route.meta.public === true)
const loading = ref(false)
const terms = ref<any[]>([])
const classes = ref<any[]>([])
const termId = ref('')
const classId = ref('')
const timetable = ref<any | null>(null)
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
const maxPeriods = computed(() => {
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
const entryMaximum = Math.max(
0,
...((timetable.value?.entries ?? []).map((x: any) => x.startPeriod + x.periodCount - 1)),
)
return Math.max(8, slotMaximum, entryMaximum)
})
const slotMap = computed<Map<number, any>>(() =>
new Map<number, any>(
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
),
)
function formatTime(value: string) {
return value?.slice(0, 5) ?? ''
}
function entryStyle(entry: any) {
return {
gridColumn: String(entry.dayOfWeek + 1),
gridRow: `${entry.startPeriod + 1} / span ${entry.periodCount}`,
}
}
function weeks(entry: any) {
const pattern = patternLabels[entry.weekPattern]
return `${entry.startWeek}${entry.endWeek}${pattern === '每周' ? '' : ` · ${pattern}`}`
}
function location(entry: any) {
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
}
async function loadOptions() {
const { data } = await http.get('/timetables/options')
terms.value = data.terms
classes.value = data.classes
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
?? data.terms[0]?.id
?? ''
classId.value = data.classes.find((item: any) => item.hasPublishedTimetable)?.id
?? data.classes[0]?.id
?? ''
}
async function loadTimetable() {
if (!termId.value || (!isMine.value && !classId.value)) return
loading.value = true
try {
const url = isMine.value ? '/timetables/mine' : `/timetables/classes/${classId.value}`
timetable.value = (await http.get(url, {
params: { academicTermId: termId.value },
})).data
} catch (error) {
timetable.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
watch([termId, classId], loadTimetable)
onMounted(async () => {
try {
await loadOptions()
await loadTimetable()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
})
</script>
<template>
<main :class="['timetable-page', { 'public-timetable': isPublic }]">
<header v-if="isPublic" class="public-header">
<router-link class="public-brand" to="/timetable">明序教务 · 课表查询</router-link>
<router-link class="login-link" to="/login">登录工作台</router-link>
</header>
<section class="timetable-heading">
<div>
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : 'CLASS TIMETABLE' }}</span>
<h2>{{ isMine ? '我的课表' : '班级课表查询' }}</h2>
<p v-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else>无需登录即可查询各行政班已正式发布的课程安排</p>
</div>
<div class="timetable-filters">
<el-select v-model="termId" filterable placeholder="选择学期">
<el-option
v-for="term in terms"
:key="term.id"
:label="`${term.name}${term.hasPublishedTimetable ? '' : '(未发布)'}`"
:value="term.id"
/>
</el-select>
<el-select
v-if="!isMine"
v-model="classId"
filterable
placeholder="选择行政班"
>
<el-option
v-for="item in classes"
:key="item.id"
:label="`${item.collegeName} · ${item.majorName} · ${item.name}`"
:value="item.id"
/>
</el-select>
</div>
</section>
<section v-loading="loading" class="timetable-sheet">
<div v-if="timetable" class="sheet-meta">
<div>
<strong>{{ timetable.class.name }}</strong>
<span>{{ timetable.class.collegeName }} · {{ timetable.class.majorName }}</span>
</div>
<div v-if="timetable.student">
<strong>{{ timetable.student.name }}</strong>
<span>{{ timetable.student.studentNumber }}</span>
</div>
<div>
<strong>{{ timetable.term.name }}</strong>
<span v-if="timetable.plan">发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}</span>
<span v-else>本学期课表尚未发布</span>
</div>
</div>
<section v-if="timetable?.flexibleCourses?.length" class="flexible-courses">
<div class="flexible-heading">
<div>
<span>非排时课程</span>
<strong>不占正常上课时间与场地</strong>
</div>
<small> {{ timetable.flexibleCourses.length }} </small>
</div>
<div class="flexible-course-list">
<article v-for="course in timetable.flexibleCourses" :key="course.id">
<span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
<strong>{{ course.courseName }}</strong>
<p>{{ course.teacherNames.join('、') || '教师待定' }}</p>
<small>
{{ course.startWeek }}{{ course.endWeek }} ·
每周 {{ course.weeklyHours }} 学时 · {{ course.totalHours }} 学时
</small>
</article>
</div>
</section>
<div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll">
<div
class="week-grid"
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
>
<div class="grid-corner">节次</div>
<div v-for="day in 7" :key="`head-${day}`" class="day-head">{{ weekdays[day] }}</div>
<template v-for="period in maxPeriods" :key="`period-${period}`">
<div class="period-head" :style="{ gridRow: String(period + 1) }">
<strong> {{ period }} </strong>
<span v-if="slotMap.get(period)">
{{ formatTime(slotMap.get(period).startsAt) }}{{ formatTime(slotMap.get(period).endsAt) }}
</span>
</div>
<div
v-for="day in 7"
:key="`cell-${period}-${day}`"
class="grid-cell"
:style="{ gridColumn: String(day + 1), gridRow: String(period + 1) }"
/>
</template>
<article
v-for="entry in timetable.entries"
:key="entry.id"
class="course-block"
:style="entryStyle(entry)"
>
<strong>{{ entry.courseName }}</strong>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small>
</article>
</div>
</div>
<el-empty
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
/>
</section>
</main>
</template>
<style scoped>
.timetable-page { min-width: 0; width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
.public-timetable { max-width: 100vw; min-height: 100vh; padding: 0 32px 40px; box-sizing: border-box; background: #f4f7fa; }
.public-header { height: 68px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce4eb; }
.public-brand { color: #17324d; font-weight: 750; text-decoration: none; letter-spacing: .02em; }
.login-link { color: #176b87; text-decoration: none; font-weight: 650; }
.timetable-heading { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 24px; padding: 24px 28px; background: #fff; border: 1px solid #dce4eb; }
.timetable-heading h2 { margin: 5px 0 8px; color: #17324d; font-size: 27px; }
.timetable-heading p { margin: 0; color: #647587; }
.timetable-filters { display: flex; flex-wrap: wrap; gap: 12px; }
.timetable-filters .el-select { width: 270px; }
.timetable-sheet { min-width: 0; min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
.sheet-meta div { display: grid; gap: 3px; }
.sheet-meta strong { color: #17324d; }
.sheet-meta span { color: #718191; font-size: 13px; }
.flexible-courses { margin-bottom: 20px; border: 1px solid #cfe1dc; background: #f5faf8; }
.flexible-heading { padding: 12px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce9e5; }
.flexible-heading > div { display: flex; align-items: baseline; gap: 10px; }
.flexible-heading span { color: #176b5d; font-size: 12px; font-weight: 750; }
.flexible-heading strong { color: #506c65; font-size: 12px; }
.flexible-heading small { color: #718b84; }
.flexible-course-list { padding: 12px; display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
.flexible-course-list article { padding: 12px 13px; display: grid; gap: 4px; border-left: 3px solid #2d8975; background: #fff; }
.flexible-course-list article > span { color: #277766; font: 700 10px/1.3 Consolas, monospace; }
.flexible-course-list article strong { color: #173f38; font-size: 14px; }
.flexible-course-list article p { margin: 0; color: #5e746f; font-size: 12px; }
.flexible-course-list article small { color: #778b86; font-size: 11px; }
.timetable-scroll { overflow: auto; }
.week-grid { min-width: 1120px; display: grid; grid-template-columns: 92px repeat(7, minmax(140px, 1fr)); position: relative; }
.grid-corner, .day-head, .period-head { z-index: 2; background: #f3f6f8; border: 1px solid #dce4eb; color: #3f5366; }
.grid-corner, .day-head { display: grid; place-items: center; font-weight: 700; }
.grid-corner { grid-column: 1; grid-row: 1; }
.day-head { grid-row: 1; }
.day-head:nth-of-type(2) { grid-column: 2; }
.day-head:nth-of-type(3) { grid-column: 3; }
.day-head:nth-of-type(4) { grid-column: 4; }
.day-head:nth-of-type(5) { grid-column: 5; }
.day-head:nth-of-type(6) { grid-column: 6; }
.day-head:nth-of-type(7) { grid-column: 7; }
.day-head:nth-of-type(8) { grid-column: 8; }
.period-head { grid-column: 1; display: grid; place-content: center; gap: 4px; text-align: center; }
.period-head span { color: #7c8b98; font-size: 11px; }
.grid-cell { border: 1px solid #e4e9ee; background: #fff; }
.course-block { z-index: 3; margin: 4px; padding: 9px 10px; overflow: hidden; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #24475a; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
.course-block strong { color: #123a4b; font-size: 14px; }
.course-block span { font-size: 12px; }
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
@media (max-width: 760px) {
.public-timetable { padding: 0 14px 24px; }
.timetable-heading, .sheet-meta { align-items: stretch; flex-direction: column; }
.timetable-filters { flex-direction: column; }
.timetable-filters .el-select { width: 100%; }
.timetable-sheet { padding: 12px; }
}
</style>