1231 lines
49 KiB
Vue
1231 lines
49 KiB
Vue
<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>
|