主要更新:

班级课表支持年级、学院、专业、行政班分级筛选。
管理员课表查询中心支持班级、教师、场地三种课表。
超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。
增加周视图、日视图切换。
支持 Excel 导出和当前视图 PDF 导出。
学生端增加“空闲教室”,支持学期、周次、星期、连续节次、校区、教学楼、容量筛选,并按教学楼分组、分页展示。
空闲教室只依据正式课表计算;未配置节次表时自动提供默认节次。
This commit is contained in:
2026-07-25 08:53:42 +08:00 Unverified
parent fe508054d0
commit 0d34936d38
15 changed files with 2411 additions and 209 deletions
+536
View File
@@ -0,0 +1,536 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Location, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
interface TermOption {
id: string
name: string
startDate: string
endDate: string
isCurrent: boolean
hasPublishedTimetable: boolean
}
interface PlaceOption {
id: string
code: string
name: string
campusId?: string
}
interface TimeSlotOption {
periodNumber: number
name: string
startTime: string
endTime: string
}
interface FreeClassroom {
id: string
code: string
name: string
capacity: number
buildingId: string
buildingName: string
campusId: string
campusName: string
}
const weekdayOptions = [
{ value: 1, label: '周一' },
{ value: 2, label: '周二' },
{ value: 3, label: '周三' },
{ value: 4, label: '周四' },
{ value: 5, label: '周五' },
{ value: 6, label: '周六' },
{ value: 7, label: '周日' },
]
const terms = ref<TermOption[]>([])
const campuses = ref<PlaceOption[]>([])
const buildings = ref<PlaceOption[]>([])
const timeSlots = ref<TimeSlotOption[]>([])
const classrooms = ref<FreeClassroom[]>([])
const loading = ref(false)
const searched = ref(false)
const resultPage = ref(1)
const pageSize = 48
const form = reactive({
academicTermId: '',
week: 1,
dayOfWeek: new Date().getDay() || 7,
startPeriod: 1,
periodCount: 1,
campusId: '',
buildingId: '',
minimumCapacity: undefined as number | undefined,
})
const selectedTerm = computed(() =>
terms.value.find((item) => item.id === form.academicTermId),
)
const filteredBuildings = computed(() =>
buildings.value.filter((item) => !form.campusId || item.campusId === form.campusId),
)
const availableStartPeriods = computed(() =>
timeSlots.value.filter((slot) =>
timeSlots.value.some((candidate) =>
candidate.periodNumber === slot.periodNumber + form.periodCount - 1,
),
),
)
const pagedClassrooms = computed(() =>
classrooms.value.slice((resultPage.value - 1) * pageSize, resultPage.value * pageSize),
)
const groupedRooms = computed(() => {
const groups = new Map<string, { campusName: string; buildingName: string; rooms: FreeClassroom[] }>()
for (const room of pagedClassrooms.value) {
const key = `${room.campusId}:${room.buildingId}`
const group = groups.get(key) ?? {
campusName: room.campusName,
buildingName: room.buildingName,
rooms: [],
}
group.rooms.push(room)
groups.set(key, group)
}
return [...groups.values()]
})
const querySummary = computed(() => {
const day = weekdayOptions.find((item) => item.value === form.dayOfWeek)?.label ?? ''
const end = form.startPeriod + form.periodCount - 1
const periods = end === form.startPeriod
? `${form.startPeriod}`
: `${form.startPeriod}${end}`
return `${form.week} 周 · ${day} · ${periods}`
})
function calculateCurrentWeek(term?: TermOption) {
if (!term) return 1
const start = new Date(`${term.startDate}T00:00:00`)
const today = new Date()
const week = Math.floor((today.getTime() - start.getTime()) / 604800000) + 1
return Math.min(30, Math.max(1, week))
}
function onCampusChanged() {
if (!filteredBuildings.value.some((item) => item.id === form.buildingId)) {
form.buildingId = ''
}
}
async function loadOptions(academicTermId?: string) {
try {
const { data } = await http.get('/timetables/free-classrooms/options', {
params: academicTermId ? { academicTermId } : undefined,
})
terms.value = data.terms
campuses.value = data.campuses
buildings.value = data.buildings
timeSlots.value = data.timeSlots
if (!form.academicTermId) {
form.academicTermId = data.selectedTermId ?? ''
form.week = calculateCurrentWeek(
terms.value.find((item) => item.id === form.academicTermId),
)
}
if (!availableStartPeriods.value.some(
(item) => item.periodNumber === form.startPeriod,
)) {
form.startPeriod = availableStartPeriods.value[0]?.periodNumber ?? 1
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function onTermChanged() {
form.week = calculateCurrentWeek(selectedTerm.value)
classrooms.value = []
searched.value = false
await loadOptions(form.academicTermId)
}
async function searchRooms() {
if (!form.academicTermId) {
ElMessage.warning('请先选择学期。')
return
}
loading.value = true
try {
const { data } = await http.get('/timetables/free-classrooms', {
params: {
academicTermId: form.academicTermId,
week: form.week,
dayOfWeek: form.dayOfWeek,
startPeriod: form.startPeriod,
periodCount: form.periodCount,
campusId: form.campusId || undefined,
buildingId: form.buildingId || undefined,
minimumCapacity: form.minimumCapacity,
},
})
classrooms.value = data.items
resultPage.value = 1
searched.value = true
} catch (error) {
classrooms.value = []
searched.value = false
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadOptions()
})
</script>
<template>
<section class="free-room-page">
<header class="page-heading">
<div>
<span class="eyebrow">STUDENT SERVICE · 空间查询</span>
<h2>空闲教室查询</h2>
<p>依据已发布课表查询指定周次和连续节次内没有排课的教学场所</p>
</div>
<el-tag type="success" effect="plain">仅使用正式课表</el-tag>
</header>
<el-card shadow="never" class="query-card">
<el-form label-position="top" class="query-form">
<el-form-item label="学期">
<el-select v-model="form.academicTermId" @change="onTermChanged">
<el-option
v-for="term in terms"
:key="term.id"
:label="term.name"
:value="term.id"
:disabled="!term.hasPublishedTimetable"
>
<span>{{ term.name }}</span>
<small v-if="!term.hasPublishedTimetable">尚未发布课表</small>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="周次">
<el-input-number v-model="form.week" :min="1" :max="30" />
</el-form-item>
<el-form-item label="星期">
<el-select v-model="form.dayOfWeek">
<el-option
v-for="item in weekdayOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="开始节次">
<el-select v-model="form.startPeriod">
<el-option
v-for="slot in availableStartPeriods"
:key="slot.periodNumber"
:label="slot.startTime
? `${slot.name}${slot.startTime.slice(0, 5)}—${slot.endTime.slice(0, 5)}`
: slot.name"
:value="slot.periodNumber"
/>
</el-select>
</el-form-item>
<el-form-item label="连续节数">
<el-input-number v-model="form.periodCount" :min="1" :max="6" />
</el-form-item>
<el-form-item label="校区">
<el-select v-model="form.campusId" clearable placeholder="全部校区" @change="onCampusChanged">
<el-option
v-for="campus in campuses"
:key="campus.id"
:label="campus.name"
:value="campus.id"
/>
</el-select>
</el-form-item>
<el-form-item label="教学楼">
<el-select v-model="form.buildingId" clearable placeholder="全部教学楼">
<el-option
v-for="building in filteredBuildings"
:key="building.id"
:label="building.name"
:value="building.id"
/>
</el-select>
</el-form-item>
<el-form-item label="至少容纳人数">
<el-input-number
v-model="form.minimumCapacity"
:min="0"
:max="10000"
placeholder="不限"
/>
</el-form-item>
<div class="query-action">
<el-button type="primary" :icon="Search" :loading="loading" @click="searchRooms">
查询空闲教室
</el-button>
</div>
</el-form>
</el-card>
<section v-loading="loading" class="result-panel">
<div class="result-heading">
<div>
<span>查询条件</span>
<h3>{{ querySummary }}</h3>
</div>
<div v-if="searched" class="result-count">
<strong>{{ classrooms.length }}</strong>
<span>间空闲教室</span>
</div>
<el-button v-if="searched" text :icon="Refresh" @click="searchRooms">刷新</el-button>
</div>
<template v-if="searched && groupedRooms.length">
<div class="building-groups">
<article v-for="group in groupedRooms" :key="`${group.campusName}-${group.buildingName}`">
<header>
<el-icon><Location /></el-icon>
<div>
<small>{{ group.campusName }}</small>
<h4>{{ group.buildingName }}</h4>
</div>
<span>{{ group.rooms.length }} </span>
</header>
<div class="room-grid">
<div v-for="room in group.rooms" :key="room.id" class="room-item">
<b>{{ room.name }}</b>
<span>{{ room.code }}</span>
<em>容纳 {{ room.capacity }} </em>
</div>
</div>
</article>
</div>
<el-pagination
v-if="classrooms.length > pageSize"
v-model:current-page="resultPage"
class="result-pagination"
background
layout="prev, pager, next, jumper"
:page-size="pageSize"
:total="classrooms.length"
/>
</template>
<el-empty
v-else-if="searched"
description="当前条件下没有空闲教室,可尝试缩短连续节数或调整场地范围。"
/>
<el-empty v-else description="设置条件后查询空闲教室" />
</section>
</section>
</template>
<style scoped>
.free-room-page {
display: grid;
gap: 20px;
}
.page-heading,
.result-heading,
.result-count,
.building-groups article > header,
.room-item {
display: flex;
align-items: center;
}
.page-heading {
justify-content: space-between;
gap: 24px;
}
.eyebrow {
color: #17867c;
font-size: 12px;
font-weight: 700;
letter-spacing: .08em;
}
h2,
h3,
h4,
p {
margin: 0;
}
h2 {
margin-top: 5px;
color: #183b56;
font-size: 25px;
}
.page-heading p {
margin-top: 8px;
color: #718096;
}
.query-card {
border-color: #dce6eb;
}
.query-form {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 2px 16px;
align-items: end;
}
.query-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.query-form :deep(.el-select),
.query-form :deep(.el-input-number) {
width: 100%;
}
.query-form small {
float: right;
margin-left: 12px;
color: #a0aec0;
}
.query-action {
padding-bottom: 12px;
}
.query-action .el-button {
width: 100%;
}
.result-panel {
min-height: 260px;
padding: 22px;
border: 1px solid #dce6eb;
background: #fff;
}
.result-heading {
gap: 18px;
padding-bottom: 18px;
border-bottom: 1px solid #edf2f5;
}
.result-heading > div:first-child {
flex: 1;
}
.result-heading span,
.building-groups small,
.room-item span {
color: #718096;
font-size: 13px;
}
.result-heading h3 {
margin-top: 3px;
color: #24445d;
font-size: 17px;
}
.result-count {
gap: 7px;
}
.result-count strong {
color: #16867c;
font-size: 24px;
}
.building-groups {
display: grid;
gap: 18px;
margin-top: 20px;
}
.result-pagination {
justify-content: flex-end;
margin-top: 20px;
}
.building-groups article {
border: 1px solid #dfe8ec;
}
.building-groups article > header {
gap: 10px;
padding: 13px 16px;
border-bottom: 1px solid #e7eef1;
background: #f5faf9;
color: #17867c;
}
.building-groups article > header div {
flex: 1;
}
.building-groups h4 {
margin-top: 2px;
color: #24445d;
}
.building-groups article > header > span {
color: #17867c;
font-size: 13px;
font-weight: 700;
}
.room-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 1px;
background: #e7eef1;
}
.room-item {
min-height: 92px;
align-items: flex-start;
flex-direction: column;
padding: 14px;
background: #fff;
}
.room-item b {
color: #24445d;
}
.room-item em {
margin-top: auto;
color: #17867c;
font-size: 13px;
font-style: normal;
}
@media (max-width: 1000px) {
.query-form {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
@media (max-width: 600px) {
.page-heading,
.result-heading {
align-items: flex-start;
flex-direction: column;
}
.query-form {
grid-template-columns: 1fr;
}
.result-panel {
padding: 16px;
}
}
</style>
+418 -32
View File
@@ -1,20 +1,85 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Document, Download } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const auth = useAuthStore()
const isMine = computed(() => route.meta.mine === true)
const isPublic = computed(() => route.meta.public === true)
const isManager = computed(() =>
!isMine.value &&
!isPublic.value &&
(auth.user?.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false),
)
const loading = ref(false)
const exportingPdf = ref(false)
const terms = ref<any[]>([])
const colleges = ref<any[]>([])
const majors = ref<any[]>([])
const classes = ref<any[]>([])
const teachers = ref<any[]>([])
const campuses = ref<any[]>([])
const buildings = ref<any[]>([])
const classrooms = ref<any[]>([])
const plans = ref<any[]>([])
const termId = ref('')
const planId = ref('')
const resourceType = ref<'Class' | 'Teacher' | 'Classroom'>('Class')
const grade = ref<number | undefined>()
const collegeId = ref('')
const majorId = ref('')
const classId = ref('')
const teacherId = ref('')
const campusId = ref('')
const buildingId = ref('')
const classroomId = ref('')
const timetable = ref<any | null>(null)
const viewMode = ref<'week' | 'day'>('week')
const selectedDay = ref(1)
const exportArea = ref<HTMLElement | null>(null)
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
const planStatusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Archived: '已归档',
}
const grades = computed(() =>
[...new Set(classes.value.map((item) => item.grade))].sort((a, b) => b - a),
)
const filteredMajors = computed(() =>
majors.value.filter((item) => !collegeId.value || item.collegeId === collegeId.value),
)
const filteredClasses = computed(() =>
classes.value.filter((item) =>
(!grade.value || item.grade === grade.value) &&
(!collegeId.value || item.collegeId === collegeId.value) &&
(!majorId.value || item.majorId === majorId.value),
),
)
const filteredTeachers = computed(() =>
teachers.value.filter((item) => !collegeId.value || item.collegeId === collegeId.value),
)
const filteredBuildings = computed(() =>
buildings.value.filter((item) => !campusId.value || item.campusId === campusId.value),
)
const filteredClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!campusId.value || item.campusId === campusId.value) &&
(!buildingId.value || item.buildingId === buildingId.value),
),
)
const selectedResourceId = computed(() => {
if (resourceType.value === 'Teacher') return teacherId.value
if (resourceType.value === 'Classroom') return classroomId.value
return classId.value
})
const maxPeriods = computed(() => {
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
const entryMaximum = Math.max(
@@ -23,12 +88,14 @@ const maxPeriods = computed(() => {
)
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]),
),
)
const dayEntries = computed(() =>
(timetable.value?.entries ?? []).filter((entry: any) => entry.dayOfWeek === selectedDay.value),
)
function formatTime(value: string) {
return value?.slice(0, 5) ?? ''
@@ -50,27 +117,132 @@ function location(entry: any) {
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
}
async function loadOptions() {
function dayEntriesAt(period: number) {
return dayEntries.value.filter((entry: any) => entry.startPeriod === period)
}
function setClassFilters(item: any) {
if (!item) return
grade.value = item.grade
collegeId.value = item.collegeId
majorId.value = item.majorId
}
async function loadPublicOptions() {
const { data } = await http.get('/timetables/options')
terms.value = data.terms
colleges.value = data.colleges
majors.value = data.majors
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
?? ''
const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable)
?? data.classes[0]
classId.value = initialClass?.id ?? ''
setClassFilters(initialClass)
}
async function loadManagementOptions() {
if (!isManager.value || !termId.value) return
const { data } = await http.get('/timetables/management/options', {
params: { academicTermId: termId.value },
})
plans.value = data.plans
colleges.value = data.colleges
majors.value = data.majors
classes.value = data.classes
teachers.value = data.teachers
campuses.value = data.campuses
buildings.value = data.buildings
classrooms.value = data.classrooms
if (!data.plans.some((item: any) => item.id === planId.value)) {
planId.value = data.plans.find((item: any) => item.status === 'Published')?.id
?? data.plans[0]?.id
?? ''
}
if (!data.classes.some((item: any) => item.id === classId.value)) {
classId.value = data.classes[0]?.id ?? ''
setClassFilters(data.classes[0])
}
if (!data.teachers.some((item: any) => item.id === teacherId.value)) {
teacherId.value = data.teachers[0]?.id ?? ''
}
if (!data.classrooms.some((item: any) => item.id === classroomId.value)) {
classroomId.value = data.classrooms[0]?.id ?? ''
}
}
async function onTermChanged() {
if (isManager.value) await loadManagementOptions()
await loadTimetable()
}
function onGradeChanged() {
classId.value = ''
}
function onCollegeChanged() {
majorId.value = ''
classId.value = ''
teacherId.value = ''
}
function onMajorChanged() {
classId.value = ''
}
function onCampusChanged() {
buildingId.value = ''
classroomId.value = ''
}
function onBuildingChanged() {
classroomId.value = ''
}
function onResourceTypeChanged() {
collegeId.value = ''
majorId.value = ''
grade.value = undefined
campusId.value = ''
buildingId.value = ''
if (resourceType.value === 'Class') {
classId.value = classes.value[0]?.id ?? ''
setClassFilters(classes.value[0])
} else if (resourceType.value === 'Teacher') {
teacherId.value = teachers.value[0]?.id ?? ''
} else {
classroomId.value = classrooms.value[0]?.id ?? ''
}
}
async function loadTimetable() {
if (!termId.value || (!isMine.value && !classId.value)) return
if (!termId.value || (!isMine.value && !selectedResourceId.value)) {
timetable.value = null
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
if (isMine.value) {
timetable.value = (await http.get('/timetables/mine', {
params: { academicTermId: termId.value },
})).data
} else if (isManager.value) {
timetable.value = (await http.get('/timetables/management/query', {
params: {
resourceType: resourceType.value,
resourceId: selectedResourceId.value,
academicTermId: termId.value,
schedulePlanId: planId.value || undefined,
},
})).data
} else {
timetable.value = (await http.get(`/timetables/classes/${classId.value}`, {
params: { academicTermId: termId.value },
})).data
}
} catch (error) {
timetable.value = null
ElMessage.error(apiErrorMessage(error))
@@ -79,11 +251,78 @@ async function loadTimetable() {
}
}
watch([termId, classId], loadTimetable)
async function exportExcel() {
try {
if (isMine.value) {
await downloadApiFile(
`/timetables/mine/export.xlsx?academicTermId=${termId.value}`,
'我的课表.xlsx',
)
} else if (isManager.value) {
const query = new URLSearchParams({
resourceType: resourceType.value,
resourceId: selectedResourceId.value,
academicTermId: termId.value,
})
if (planId.value) query.set('schedulePlanId', planId.value)
await downloadApiFile(
`/timetables/management/export.xlsx?${query}`,
`${timetable.value?.subject?.name ?? '课表'}.xlsx`,
)
} else {
await downloadApiFile(
`/timetables/classes/${classId.value}/export.xlsx?academicTermId=${termId.value}`,
`${timetable.value?.subject?.name ?? '班级'}课表.xlsx`,
)
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function exportPdf() {
if (!exportArea.value || !timetable.value) return
exportingPdf.value = true
try {
await nextTick()
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas'),
import('jspdf'),
])
const canvas = await html2canvas(exportArea.value, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
})
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
const pageWidth = 297
const pageHeight = 210
const imageHeight = canvas.height * pageWidth / canvas.width
const image = canvas.toDataURL('image/png')
let remaining = imageHeight
let position = 0
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
remaining -= pageHeight
while (remaining > 0) {
position = remaining - imageHeight
pdf.addPage()
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
remaining -= pageHeight
}
pdf.save(`${timetable.value.subject?.name ?? '课表'}-${timetable.value.term.name}.pdf`)
} catch (error) {
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
} finally {
exportingPdf.value = false
}
}
watch([classId, teacherId, classroomId, planId, resourceType], loadTimetable)
onMounted(async () => {
try {
await loadOptions()
await loadPublicOptions()
if (isManager.value) await loadManagementOptions()
await loadTimetable()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
@@ -100,41 +339,128 @@ onMounted(async () => {
<section class="timetable-heading">
<div>
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : 'CLASS TIMETABLE' }}</span>
<h2>{{ isMine ? '我的课表' : '班级课表查询' }}</h2>
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span>
<h2>{{ isMine ? '我的课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
<p v-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else>无需登录即可查询各行政班已正式发布的课程安排</p>
<p v-else-if="isManager">查询班级教师与场地课表可切换草稿或正式发布版本</p>
<p v-else>按年级学院专业和班级分类查询正式发布的课程安排</p>
</div>
<div class="timetable-filters">
<el-select v-model="termId" filterable placeholder="选择学期">
<el-select v-model="termId" filterable placeholder="选择学期" @change="onTermChanged">
<el-option
v-for="term in terms"
:key="term.id"
:label="`${term.name}${term.hasPublishedTimetable ? '' : '(未发布)'}`"
:label="`${term.name}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
:value="term.id"
/>
</el-select>
<el-select
v-if="!isMine"
v-model="classId"
filterable
placeholder="选择行政班"
>
<el-select v-if="isManager" v-model="planId" placeholder="选择课表版本">
<el-option
v-for="item in classes"
v-for="item in plans"
:key="item.id"
:label="`${item.collegeName} · ${item.majorName} · ${item.name}`"
:label="`${item.version} · ${item.name} · ${planStatusLabels[item.status]}`"
:value="item.id"
/>
>
<span>{{ item.version }} · {{ item.name }}</span>
<el-tag
size="small"
:type="item.status === 'Published' ? 'success' : 'warning'"
>{{ planStatusLabels[item.status] }}</el-tag>
</el-option>
</el-select>
</div>
</section>
<section v-if="!isMine" class="resource-filter-panel">
<div v-if="isManager" class="resource-type-tabs">
<el-radio-group v-model="resourceType" @change="onResourceTypeChanged">
<el-radio-button value="Class">班级课表</el-radio-button>
<el-radio-button value="Teacher">教师课表</el-radio-button>
<el-radio-button value="Classroom">场地课表</el-radio-button>
</el-radio-group>
</div>
<div v-if="resourceType === 'Class'" class="hierarchy-filters">
<el-select v-model="grade" clearable placeholder="全部年级" @change="onGradeChanged">
<el-option v-for="item in grades" :key="item" :label="`${item} 级`" :value="item" />
</el-select>
<el-select v-model="collegeId" clearable placeholder="全部学院" @change="onCollegeChanged">
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="majorId" clearable placeholder="全部专业" @change="onMajorChanged">
<el-option v-for="item in filteredMajors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="classId" filterable placeholder="选择行政班">
<el-option
v-for="item in filteredClasses"
:key="item.id"
:label="`${item.code} · ${item.name}`"
:value="item.id"
/>
</el-select>
</div>
<div v-else-if="resourceType === 'Teacher'" class="hierarchy-filters compact">
<el-select v-model="collegeId" clearable placeholder="全部学院" @change="onCollegeChanged">
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="teacherId" filterable placeholder="选择教师">
<el-option
v-for="item in filteredTeachers"
:key="item.id"
:label="`${item.teacherNumber} · ${item.name}${item.title ? ` · ${item.title}` : ''}`"
:value="item.id"
/>
</el-select>
</div>
<div v-else class="hierarchy-filters compact">
<el-select v-model="campusId" clearable placeholder="全部校区" @change="onCampusChanged">
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="buildingId" clearable placeholder="全部教学楼" @change="onBuildingChanged">
<el-option v-for="item in filteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="classroomId" filterable placeholder="选择教室">
<el-option
v-for="item in filteredClassrooms"
:key="item.id"
:label="`${item.buildingName} · ${item.name}${item.capacity} 人)`"
:value="item.id"
/>
</el-select>
</div>
<small>
当前可选
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
</small>
</section>
<section v-loading="loading" class="timetable-sheet">
<div v-if="timetable" class="sheet-meta">
<div v-if="timetable" class="timetable-toolbar">
<div>
<strong>{{ timetable.class.name }}</strong>
<span>{{ timetable.class.collegeName }} · {{ timetable.class.majorName }}</span>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="week">周视图</el-radio-button>
<el-radio-button value="day">日视图</el-radio-button>
</el-radio-group>
<el-select v-if="viewMode === 'day'" v-model="selectedDay" size="small">
<el-option v-for="day in 7" :key="day" :label="weekdays[day]" :value="day" />
</el-select>
</div>
<div>
<el-button :icon="Download" @click="exportExcel">导出 Excel</el-button>
<el-button :icon="Document" :loading="exportingPdf" @click="exportPdf">导出 PDF</el-button>
</div>
</div>
<div v-if="timetable" ref="exportArea" class="timetable-export-area">
<div class="sheet-meta">
<div>
<strong>{{ timetable.subject.name }}</strong>
<span>
{{ timetable.subject.code }}
<template v-if="timetable.subject.collegeName"> · {{ timetable.subject.collegeName }}</template>
<template v-if="timetable.subject.majorName"> · {{ timetable.subject.majorName }}</template>
<template v-if="timetable.subject.description"> · {{ timetable.subject.description }}</template>
</span>
</div>
<div v-if="timetable.student">
<strong>{{ timetable.student.name }}</strong>
@@ -142,7 +468,12 @@ onMounted(async () => {
</div>
<div>
<strong>{{ timetable.term.name }}</strong>
<span v-if="timetable.plan">发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}</span>
<span v-if="timetable.plan">
{{ timetable.plan.version }} · {{ planStatusLabels[timetable.plan.status] }}
<template v-if="timetable.plan.publishedAt">
· 发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}
</template>
</span>
<span v-else>本学期课表尚未发布</span>
</div>
</div>
@@ -168,7 +499,7 @@ onMounted(async () => {
</div>
</section>
<div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll">
<div v-if="viewMode === 'week' && timetable?.plan && timetable.entries.length" class="timetable-scroll">
<div
class="week-grid"
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
@@ -202,10 +533,34 @@ onMounted(async () => {
</article>
</div>
</div>
<div v-else-if="viewMode === 'day' && timetable?.plan" class="day-view">
<header>
<strong>{{ weekdays[selectedDay] }}</strong>
<span>按开始节次排列 · {{ dayEntries.length }} 项安排</span>
</header>
<div v-for="period in maxPeriods" :key="`day-${period}`" class="day-period-row">
<div class="day-period-label">
<strong> {{ period }} </strong>
<span v-if="slotMap.get(period)">
{{ formatTime(slotMap.get(period).startsAt) }}{{ formatTime(slotMap.get(period).endsAt) }}
</span>
</div>
<div class="day-course-list">
<article v-for="entry in dayEntriesAt(period)" :key="entry.id">
<strong>{{ entry.courseName }}</strong>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · 连续 {{ entry.periodCount }} </small>
</article>
<span v-if="!dayEntriesAt(period).length" class="day-empty">无课程安排</span>
</div>
</div>
</div>
<el-empty
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
/>
</div>
</section>
</main>
</template>
@@ -221,7 +576,16 @@ onMounted(async () => {
.timetable-heading p { margin: 0; color: #647587; }
.timetable-filters { display: flex; flex-wrap: wrap; gap: 12px; }
.timetable-filters .el-select { width: 270px; }
.resource-filter-panel { padding: 16px 18px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; border: 1px solid #dce4eb; border-left: 4px solid #176b87; background: #fff; }
.resource-type-tabs { flex-shrink: 0; }
.hierarchy-filters { min-width: 0; display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 10px; }
.hierarchy-filters.compact { grid-template-columns: repeat(3, minmax(170px, 260px)); }
.resource-filter-panel > small { color: #718191; white-space: nowrap; }
.timetable-sheet { min-width: 0; min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
.timetable-toolbar { margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.timetable-toolbar > div { display: flex; align-items: center; gap: 10px; }
.timetable-toolbar .el-select { width: 120px; }
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
.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; }
@@ -258,11 +622,33 @@ onMounted(async () => {
.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; }
.day-view { border: 1px solid #dce4eb; }
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
.day-view > header strong { font-size: 18px; }
.day-view > header span { color: #dce8f3; font-size: 12px; }
.day-period-row { min-height: 88px; display: grid; grid-template-columns: 120px minmax(0, 1fr); border-top: 1px solid #e1e7eb; }
.day-period-label { padding: 12px; display: grid; place-content: center; gap: 4px; text-align: center; background: #f2f5f7; color: #43586a; }
.day-period-label span { color: #7b8b98; font-size: 11px; }
.day-course-list { padding: 9px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; align-items: stretch; }
.day-course-list article { padding: 10px 12px; display: grid; gap: 3px; border-left: 4px solid #176b87; background: #e9f3f5; color: #395d6c; }
.day-course-list article strong { color: #123a4b; }
.day-course-list article span, .day-course-list article small { font-size: 11px; }
.day-empty { align-self: center; color: #9aa7b1; font-size: 12px; }
@media (max-width: 1100px) {
.resource-filter-panel { grid-template-columns: 1fr; }
.hierarchy-filters { grid-template-columns: repeat(2, minmax(150px, 1fr)); }
.resource-filter-panel > small { white-space: normal; }
}
@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%; }
.hierarchy-filters, .hierarchy-filters.compact { grid-template-columns: 1fr; }
.timetable-toolbar { align-items: stretch; flex-direction: column; }
.timetable-toolbar > div { flex-wrap: wrap; }
.timetable-sheet { padding: 12px; }
.day-period-row { grid-template-columns: 90px minmax(0, 1fr); }
.day-course-list { grid-template-columns: 1fr; }
}
</style>