Files
Academic-Affairs-System/web/src/views/TimetableView.vue
T
biss a61dacff1a 考试按实际日期、周次和节次融入周/日/总视图,并用橙色“考试”卡片区分。
即使固定课表尚未发布,只要考试已发布,也能正常显示。
考试计划、考场、监考教师和日期都会显示。
Excel 导出同步包含考试安排。
2026-07-28 14:16:24 +08:00

1231 lines
49 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 {
ArrowLeft,
ArrowRight,
Calendar,
CopyDocument,
Document,
Download,
RefreshRight,
SwitchButton,
} 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'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const route = useRoute()
const auth = useAuthStore()
const isMine = computed(() => route.meta.mine === true)
const isPublic = computed(() => route.meta.public === true)
const isTeacherView = computed(() => route.meta.teacherView === true)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)))
const teacherIdParam = computed(() => route.params.teacherId as string | undefined)
const isManager = computed(() =>
!isMine.value &&
!isPublic.value &&
!isTeacherView.value &&
(auth.user?.roles.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false),
)
const loading = ref(false)
const exportingPdf = ref(false)
const calendarDialogVisible = ref(false)
const calendarLoading = ref(false)
const calendarActionLoading = ref(false)
const calendarSubscription = ref<any | null>(null)
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<'overview' | 'week' | 'day'>('week')
const selectedWeek = ref(1)
const selectedDay = ref(1)
const loadedTermId = ref('')
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 slotMap = computed<Map<number, any>>(() =>
new Map<number, any>(
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
),
)
const termMonday = computed(() => {
const start = parseDate(timetable.value?.term?.startDate)
if (!start) return null
const offset = start.getDay() === 0 ? 6 : start.getDay() - 1
return addDays(start, -offset)
})
const examEntries = computed(() => {
if (!termMonday.value) return []
return (timetable.value?.examEntries ?? []).flatMap((entry: any) => {
const examDate = parseDate(entry.examDate)
if (!examDate) return []
const week = Math.floor(
(examDate.getTime() - termMonday.value!.getTime()) / (7 * 86400000),
) + 1
if (week < 1) return []
return [{
...entry,
startWeek: week,
endWeek: week,
weekPattern: 'All',
}]
})
})
const timedEntries = computed(() => [
...(timetable.value?.entries ?? []),
...examEntries.value,
])
const hasTimedEntries = computed(() => timedEntries.value.length > 0)
const maxPeriods = computed(() => {
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
const entryMaximum = Math.max(
0,
...(timedEntries.value.map((x: any) => x.startPeriod + x.periodCount - 1)),
)
return Math.max(8, slotMaximum, entryMaximum)
})
const totalWeeks = computed(() => {
const end = parseDate(timetable.value?.term?.endDate)
if (!termMonday.value || !end) return 1
const dateWeeks = Math.ceil((end.getTime() - termMonday.value.getTime() + 86400000) /
(7 * 86400000))
const entryWeeks = Math.max(
1,
...(timedEntries.value.map((entry: any) => entry.endWeek)),
)
return Math.max(1, dateWeeks, entryWeeks)
})
const currentTermWeek = computed(() => {
if (!termMonday.value) return null
const now = new Date()
now.setHours(0, 0, 0, 0)
const week = Math.floor((now.getTime() - termMonday.value.getTime()) /
(7 * 86400000)) + 1
return week >= 1 && week <= totalWeeks.value ? week : null
})
const weekEntries = computed(() =>
timedEntries.value.filter((entry: any) =>
occursInWeek(entry, selectedWeek.value)),
)
const dayEntries = computed(() =>
weekEntries.value.filter((entry: any) => entry.dayOfWeek === selectedDay.value),
)
const visibleGridEntries = computed(() =>
viewMode.value === 'overview'
? timedEntries.value
: weekEntries.value,
)
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
const selectedDayDate = computed(() => {
if (!termMonday.value) return ''
return formatMonthDay(addDays(
termMonday.value,
(selectedWeek.value - 1) * 7 + selectedDay.value - 1,
))
})
function formatTime(value: string) {
return value?.slice(0, 5) ?? ''
}
function gridEntryStyle(entry: any) {
return withLaneStyle(entry, visibleGridEntries.value, {
gridColumn: String(entry.dayOfWeek + 1),
gridRow: `${entry.startPeriod + 1} / span ${entry.periodCount}`,
})
}
function dayEntryStyle(entry: any) {
return withLaneStyle(entry, dayEntries.value, {
gridColumn: '2',
gridRow: `${entry.startPeriod} / span ${entry.periodCount}`,
})
}
function weeks(entry: any) {
const pattern = patternLabels[entry.weekPattern]
return `${entry.startWeek}${entry.endWeek}${pattern === '每周' ? '' : ` · ${pattern}`}`
}
function entryKey(entry: any) {
return `${entry.isExam ? 'exam' : 'course'}-${entry.id}-${entry.teachingTaskId ?? ''}`
}
function entryTiming(entry: any) {
const periods = `第 ${entry.startPeriod}${entry.startPeriod + entry.periodCount - 1} 节`
return entry.isExam
? `${formatExamDate(entry.examDate)} · ${periods}`
: `${weeks(entry)} · ${periods}`
}
function formatExamDate(value?: string) {
const date = parseDate(value)
return date ? `${date.getMonth() + 1}${date.getDate()}日` : '考试日期待定'
}
function location(entry: any) {
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
}
function parseDate(value?: string) {
if (!value) return null
const date = new Date(`${value.slice(0, 10)}T00:00:00`)
return Number.isNaN(date.getTime()) ? null : date
}
function addDays(date: Date, days: number) {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
function formatMonthDay(date: Date) {
return `${date.getMonth() + 1}${date.getDate()}日`
}
function weekLabel(week: number) {
if (!termMonday.value) return `第 ${week} 周`
const start = addDays(termMonday.value, (week - 1) * 7)
const termEnd = parseDate(timetable.value?.term?.endDate)
const calculatedEnd = addDays(start, 6)
const end = termEnd && calculatedEnd > termEnd ? termEnd : calculatedEnd
return `第 ${week} 周 · ${formatMonthDay(start)}${formatMonthDay(end)}`
}
function weekdayDate(day: number) {
if (viewMode.value !== 'week' || !termMonday.value) return ''
return formatMonthDay(addDays(
termMonday.value,
(selectedWeek.value - 1) * 7 + day - 1,
))
}
function occursInWeek(entry: any, week: number) {
if (week < entry.startWeek || week > entry.endWeek) return false
if (entry.weekPattern === 'Odd') return week % 2 === 1
if (entry.weekPattern === 'Even') return week % 2 === 0
return true
}
function laneFor(entry: any, entries: any[]) {
const sameDay = entries
.filter((item: any) => item.dayOfWeek === entry.dayOfWeek)
.slice()
.sort((a: any, b: any) =>
a.startPeriod - b.startPeriod ||
b.periodCount - a.periodCount ||
String(a.id).localeCompare(String(b.id)),
)
const layouts = new Map<string, { index: number; count: number }>()
let cluster: any[] = []
let clusterEnd = -1
const placeCluster = () => {
if (!cluster.length) return
const laneEnds: number[] = []
const placed = cluster.map((item: any) => {
const start = item.startPeriod
const end = item.startPeriod + item.periodCount
let index = laneEnds.findIndex((laneEnd) => laneEnd <= start)
if (index === -1) {
index = laneEnds.length
laneEnds.push(end)
} else {
laneEnds[index] = end
}
return { item, index }
})
const count = Math.max(1, laneEnds.length)
placed.forEach(({ item, index }) => layouts.set(entryKey(item), { index, count }))
}
sameDay.forEach((item: any) => {
const itemEnd = item.startPeriod + item.periodCount
if (cluster.length && item.startPeriod >= clusterEnd) {
placeCluster()
cluster = []
clusterEnd = -1
}
cluster.push(item)
clusterEnd = Math.max(clusterEnd, itemEnd)
})
placeCluster()
return layouts.get(entryKey(entry)) ?? { index: 0, count: 1 }
}
function withLaneStyle(entry: any, entries: any[], base: Record<string, string>) {
const lane = laneFor(entry, entries)
return {
...base,
width: `calc(${100 / lane.count}% - 8px)`,
marginLeft: `calc(${100 / lane.count * lane.index}% + 4px)`,
}
}
function syncSelectedWeek() {
const termKey = timetable.value?.term?.id ?? ''
if (termKey !== loadedTermId.value) {
loadedTermId.value = termKey
selectedWeek.value = currentTermWeek.value ?? 1
} else {
selectedWeek.value = Math.min(Math.max(1, selectedWeek.value), totalWeeks.value)
}
}
function changeWeek(delta: number) {
selectedWeek.value = Math.min(
totalWeeks.value,
Math.max(1, selectedWeek.value + delta),
)
}
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 = defaultAcademicTermId(data.terms)
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.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) {
timetable.value = null
return
}
if (!isMine.value && !isTeacherView.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 (isTeacherView.value && teacherIdParam.value) {
timetable.value = (await http.get(`/timetables/teachers/${teacherIdParam.value}`, {
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
}
syncSelectedWeek()
} 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 (isTeacherView.value && teacherIdParam.value) {
await downloadApiFile(
`/timetables/teachers/${teacherIdParam.value}/export.xlsx?academicTermId=${termId.value}`,
`${timetable.value?.subject?.name ?? '教师'}课表.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
}
}
async function openCalendarSubscription() {
calendarDialogVisible.value = true
calendarLoading.value = true
try {
calendarSubscription.value = normalizeCalendarSubscription((
await http.get('/timetables/mine/calendar-subscription')
).data)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
calendarLoading.value = false
}
}
async function enableCalendarSubscription() {
calendarActionLoading.value = true
try {
calendarSubscription.value = normalizeCalendarSubscription((
await http.post('/timetables/mine/calendar-subscription')
).data)
ElMessage.success('个人教学日历订阅已启用')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
calendarActionLoading.value = false
}
}
async function rotateCalendarSubscription() {
try {
await ElMessageBox.confirm(
'重置后,已添加到日历客户端的旧地址会立即失效,需要使用新地址重新订阅。',
'重置订阅地址',
{ type: 'warning', confirmButtonText: '确认重置', cancelButtonText: '取消' },
)
} catch {
return
}
calendarActionLoading.value = true
try {
calendarSubscription.value = normalizeCalendarSubscription((
await http.post('/timetables/mine/calendar-subscription/rotate')
).data)
ElMessage.success('订阅地址已重置')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
calendarActionLoading.value = false
}
}
async function disableCalendarSubscription() {
try {
await ElMessageBox.confirm(
'停用后,所有日历客户端都无法再通过当前地址获取教学安排。',
'停用日历订阅',
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' },
)
} catch {
return
}
calendarActionLoading.value = true
try {
await http.delete('/timetables/mine/calendar-subscription')
calendarSubscription.value = { isEnabled: false }
ElMessage.success('个人教学日历订阅已停用')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
calendarActionLoading.value = false
}
}
async function copyCalendarUrl() {
const value = calendarSubscription.value?.feedUrl
if (!value) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
} else {
const input = document.createElement('textarea')
input.value = value
input.style.position = 'fixed'
input.style.opacity = '0'
document.body.appendChild(input)
input.select()
document.execCommand('copy')
input.remove()
}
ElMessage.success('订阅地址已复制')
} catch {
ElMessage.error('复制失败,请手动选择订阅地址。')
}
}
function openCalendarClient() {
const value = calendarSubscription.value?.webcalUrl
if (value) window.location.href = value
}
function normalizeCalendarSubscription(value: any) {
if (!value?.feedPath) return value
const configuredBase = String(http.defaults.baseURL ?? '/api')
const publicBase = import.meta.env.VITE_PUBLIC_BASE_URL ?? window.location.origin
const apiBase = new URL(
configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`,
publicBase,
)
const feedUrl = new URL(value.feedPath, apiBase).toString()
return {
...value,
feedUrl,
webcalUrl: feedUrl.replace(/^https?:\/\//i, 'webcal://'),
}
}
async function downloadCalendarSnapshot() {
try {
await downloadApiFile(
'/timetables/mine/calendar.ics',
'个人教学日历.ics',
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
watch([classId, teacherId, classroomId, planId, resourceType], loadTimetable)
watch(teacherIdParam, (newId) => { if (newId) loadTimetable() })
onMounted(async () => {
try {
if (isTeacherView.value) {
const { data } = await http.get('/timetables/options')
terms.value = data.terms
termId.value = defaultAcademicTermId(data.terms) ?? ''
await loadTimetable()
return
}
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 && isTeacher ? 'MY TEACHING SCHEDULE' : isMine ? 'MY TIMETABLE' : isTeacherView ? 'TEACHER TIMETABLE' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span>
<h2>{{ isMine && isTeacher ? '我的授课课表' : isMine ? '我的课表' : isTeacherView ? '教师课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
<p v-if="isMine && isTeacher">展示本人本学期所有授课安排仅采用教务处已发布课表</p>
<p v-else-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else-if="isTeacherView">查看指定教师本学期的授课安排</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="`${academicTermLabel(term)}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
:value="term.id"
:class="academicTermOptionClass(term)"
/>
</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 && !isTeacherView" 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="overview">总视图</el-radio-button>
<el-radio-button value="week">周视图</el-radio-button>
<el-radio-button value="day">日视图</el-radio-button>
</el-radio-group>
<div v-if="viewMode !== 'overview'" class="week-switcher">
<el-button
:icon="ArrowLeft"
circle
size="small"
aria-label="上一周"
:disabled="selectedWeek <= 1"
@click="changeWeek(-1)"
/>
<el-select v-model="selectedWeek" size="small" aria-label="选择教学周">
<el-option
v-for="week in totalWeeks"
:key="week"
:label="weekLabel(week)"
:value="week"
/>
</el-select>
<el-button
:icon="ArrowRight"
circle
size="small"
aria-label="下一周"
:disabled="selectedWeek >= totalWeeks"
@click="changeWeek(1)"
/>
<el-button
v-if="currentTermWeek"
size="small"
:disabled="selectedWeek === currentTermWeek"
@click="selectedWeek = currentTermWeek"
>
回到本周
</el-button>
</div>
<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 v-if="isMine" :icon="Calendar" @click="openCalendarSubscription">
订阅日历
</el-button>
<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>
<template v-if="examEntries.length">
· 已融入 {{ examEntries.length }} 场考试
</template>
</span>
<span v-else-if="examEntries.length">
固定课表尚未发布 · 已载入 {{ examEntries.length }} 场考试
</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 === 'overview' || viewMode === 'week') && hasTimedEntries"
class="timetable-view-section"
>
<div class="view-context">
<div>
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
<span>
{{ viewMode === 'overview'
? '固定课程与已发布考试按节次合并显示'
: `本周共 ${weekEntries.length} 项课程、考试安排` }}
</span>
</div>
<small v-if="viewMode === 'overview'">考试卡片显示具体日期课程卡片保留周次信息</small>
<small v-else-if="!weekEntries.length">本周没有课程或考试安排</small>
</div>
<div 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">
<strong>{{ weekdays[day] }}</strong>
<small v-if="weekdayDate(day)">{{ weekdayDate(day) }}</small>
</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 visibleGridEntries"
:key="entryKey(entry)"
class="course-block"
:class="{ 'exam-block': entry.isExam }"
:style="gridEntryStyle(entry)"
>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
{{ entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ entryTiming(entry) }}</small>
</article>
</div>
</div>
</div>
<div v-else-if="viewMode === 'day' && hasTimedEntries" class="day-view">
<header>
<div>
<strong>{{ weekdays[selectedDay] }} · {{ selectedDayDate }}</strong>
<small>{{ selectedWeekLabel }}</small>
</div>
<span> {{ dayEntries.length }} 项安排 · 连续课程按实际节数占格</span>
</header>
<div
class="day-grid"
:style="{ gridTemplateRows: `repeat(${maxPeriods}, 92px)` }"
>
<template v-for="period in maxPeriods" :key="`day-${period}`">
<div class="day-period-label" :style="{ gridRow: String(period) }">
<strong> {{ period }} </strong>
<span v-if="slotMap.get(period)">
{{ formatTime(slotMap.get(period).startsAt) }}{{ formatTime(slotMap.get(period).endsAt) }}
</span>
</div>
<div
class="day-grid-cell"
:style="{ gridColumn: '2', gridRow: String(period) }"
/>
</template>
<article
v-for="entry in dayEntries"
:key="entryKey(entry)"
class="day-course-block"
:class="{ 'exam-block': entry.isExam }"
:style="dayEntryStyle(entry)"
>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
{{ entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ entryTiming(entry) }}</small>
</article>
<span v-if="!dayEntries.length" class="day-no-courses">当日无课程安排</span>
</div>
</div>
<el-empty
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
:description="timetable.plan ? '该课表暂时没有课程或考试安排' : '所选学期尚未发布课表或考试安排'"
/>
</div>
</section>
<el-dialog
v-model="calendarDialogVisible"
title="个人教学日历订阅"
width="640px"
class="calendar-subscription-dialog"
>
<div v-loading="calendarLoading" class="calendar-subscription">
<template v-if="calendarSubscription?.isEnabled">
<div class="calendar-status active">
<span>LIVE FEED</span>
<div>
<strong>订阅已启用</strong>
<small>课表和考试安排变更后日历客户端会在下次同步时自动更新</small>
</div>
</div>
<div class="calendar-url-field">
<label>订阅地址</label>
<el-input :model-value="calendarSubscription.feedUrl" readonly>
<template #append>
<el-button :icon="CopyDocument" aria-label="复制订阅地址" @click="copyCalendarUrl" />
</template>
</el-input>
<small>此地址等同于访问密钥请勿分享到公开群聊或网页</small>
</div>
<div class="calendar-actions">
<el-button type="primary" :icon="Calendar" @click="openCalendarClient">
在日历客户端中订阅
</el-button>
<el-button :icon="Download" @click="downloadCalendarSnapshot">
下载一次性 ICS
</el-button>
</div>
<div class="calendar-security-actions">
<el-button
:icon="RefreshRight"
:loading="calendarActionLoading"
@click="rotateCalendarSubscription"
>重置地址</el-button>
<el-button
type="danger"
plain
:icon="SwitchButton"
:loading="calendarActionLoading"
@click="disableCalendarSubscription"
>停用订阅</el-button>
</div>
</template>
<template v-else-if="calendarSubscription">
<div class="calendar-status">
<span>PRIVATE CALENDAR</span>
<div>
<strong>将教学安排同步到常用日历</strong>
<small>启用后会生成仅属于你的长期订阅地址可随时重置或停用</small>
</div>
</div>
<el-button
type="primary"
:icon="Calendar"
:loading="calendarActionLoading"
@click="enableCalendarSubscription"
>启用个人日历订阅</el-button>
<el-button :icon="Download" @click="downloadCalendarSnapshot">
仅下载一次性 ICS
</el-button>
</template>
<div class="calendar-coverage">
<strong>自动同步范围</strong>
<ul>
<li>已发布课表中的固定课程和调停课后的最新安排</li>
<li>学生考试补考教师授课监考和补考监考</li>
<li>非排时课程会在学期首日生成全天提醒</li>
</ul>
<p>苹果日历可直接点击订阅Google 日历Outlook 可复制地址后选择通过 URL 添加”。服务需具备外网可访问的 HTTPS 地址才能跨设备同步</p>
</div>
</div>
</el-dialog>
</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-toolbar .week-switcher { display: flex; align-items: center; gap: 6px; padding-left: 2px; }
.timetable-toolbar .week-switcher .el-select { width: 245px; }
.calendar-subscription { min-height: 180px; display: grid; gap: 18px; }
:global(.calendar-subscription-dialog) { max-width: calc(100vw - 24px); }
.calendar-status { padding: 17px 18px; display: grid; grid-template-columns: 105px 1fr; gap: 18px; border: 1px solid #dce4eb; border-left: 4px solid #7890a4; background: #f8fafb; }
.calendar-status.active { border-left-color: #27806e; background: #f3faf7; }
.calendar-status > span { color: #637d92; font: 700 10px/1.4 Consolas, monospace; letter-spacing: .08em; }
.calendar-status.active > span { color: #277866; }
.calendar-status > div { display: grid; gap: 5px; }
.calendar-status strong { color: #17324d; }
.calendar-status small { color: #637587; line-height: 1.6; }
.calendar-url-field { display: grid; gap: 8px; }
.calendar-url-field label, .calendar-coverage > strong { color: #314c62; font-size: 13px; font-weight: 700; }
.calendar-url-field small { color: #a36922; }
.calendar-actions, .calendar-security-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.calendar-security-actions { padding-top: 16px; border-top: 1px solid #e2e8ed; }
.calendar-coverage { padding: 16px 18px; border: 1px solid #dce4eb; background: #f8fafb; }
.calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; }
.calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
.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; }
.timetable-view-section { border: 1px solid #dce4eb; }
.view-context { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #dce4eb; background: #f8fafb; }
.view-context > div { display: grid; gap: 3px; }
.view-context strong { color: #17324d; font-size: 14px; }
.view-context span, .view-context small { color: #718191; font-size: 11px; }
.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; }
.day-head { align-content: center; gap: 2px; }
.day-head small { color: #7a8996; font-size: 10px; font-weight: 500; }
.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; box-sizing: border-box; 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; }
.course-block.exam-block, .day-course-block.exam-block { border-left-color: #b65b32; background: #fff1e8; color: #70442f; }
.course-block.exam-block strong, .day-course-block.exam-block strong { color: #78391e; }
.course-block.exam-block small, .day-course-block.exam-block small { color: #8b5b43; }
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
.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 > div { display: grid; gap: 3px; }
.day-view > header strong { font-size: 18px; }
.day-view > header small { color: #bed1e4; font-size: 11px; }
.day-view > header span { color: #dce8f3; font-size: 12px; }
.day-grid { display: grid; grid-template-columns: 120px minmax(0, 1fr); position: relative; }
.day-period-label { z-index: 2; grid-column: 1; padding: 12px; display: grid; place-content: center; gap: 4px; border-top: 1px solid #e1e7eb; text-align: center; background: #f2f5f7; color: #43586a; }
.day-period-label span { color: #7b8b98; font-size: 11px; }
.day-grid-cell { border-top: 1px solid #e1e7eb; background: #fff; }
.day-course-block { z-index: 3; margin: 4px; padding: 10px 12px; overflow: hidden; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #395d6c; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
.day-course-block strong { color: #123a4b; }
.day-course-block span, .day-course-block small { font-size: 11px; }
.day-course-block small { margin-top: auto; color: #5f7885; }
.day-no-courses { z-index: 3; grid-column: 2; grid-row: 1; align-self: center; justify-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-toolbar .week-switcher { width: 100%; padding-left: 0; }
.timetable-toolbar .week-switcher .el-select { min-width: 190px; flex: 1; }
:global(.calendar-subscription-dialog) { margin-top: 4vh; }
.calendar-status { grid-template-columns: 1fr; gap: 7px; }
.calendar-actions, .calendar-security-actions { display: grid; grid-template-columns: 1fr; }
.calendar-actions .el-button, .calendar-security-actions .el-button { width: 100%; margin-left: 0; }
.timetable-sheet { padding: 12px; }
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
.day-grid { grid-template-columns: 90px minmax(0, 1fr); }
}
</style>