Files
Academic-Affairs-System/web/src/views/TimetableView.vue
T
biss 0d34936d38 主要更新:
班级课表支持年级、学院、专业、行政班分级筛选。
管理员课表查询中心支持班级、教师、场地三种课表。
超级管理员、校级教务、学院教务、领导可查询草稿和已发布版本;学院管理员保留学院数据范围。
增加周视图、日视图切换。
支持 Excel 导出和当前视图 PDF 导出。
学生端增加“空闲教室”,支持学期、周次、星期、连续节次、校区、教学楼、容量筛选,并按教学楼分组、分页展示。
空闲教室只依据正式课表计算;未配置节次表时自动提供默认节次。
2026-07-25 08:53:42 +08:00

655 lines
28 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, 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(
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]),
),
)
const dayEntries = computed(() =>
(timetable.value?.entries ?? []).filter((entry: any) => entry.dayOfWeek === selectedDay.value),
)
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(' · ')
}
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
?? ''
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 && !selectedResourceId.value)) {
timetable.value = null
return
}
loading.value = true
try {
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))
} finally {
loading.value = false
}
}
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 loadPublicOptions()
if (isManager.value) await loadManagementOptions()
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' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span>
<h2>{{ isMine ? '我的课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
<p v-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else-if="isManager">查询班级教师与场地课表可切换草稿或正式发布版本</p>
<p v-else>按年级学院专业和班级分类查询正式发布的课程安排</p>
</div>
<div class="timetable-filters">
<el-select v-model="termId" filterable placeholder="选择学期" @change="onTermChanged">
<el-option
v-for="term in terms"
:key="term.id"
:label="`${term.name}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
:value="term.id"
/>
</el-select>
<el-select v-if="isManager" v-model="planId" placeholder="选择课表版本">
<el-option
v-for="item in plans"
:key="item.id"
: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="timetable-toolbar">
<div>
<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>
<span>{{ timetable.student.studentNumber }}</span>
</div>
<div>
<strong>{{ timetable.term.name }}</strong>
<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>
<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="viewMode === 'week' && 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>
<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>
<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; }
.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; }
.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; }
.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>