已完善个人教学日历订阅。

主要能力:
教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。
自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。
采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。
支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。
订阅地址按实际前端 API 地址生成,兼容独立部署域名。
已增加窄屏弹窗最大宽度和纵向操作布局。
This commit is contained in:
2026-07-26 17:22:58 +08:00 Unverified
parent ec28eb6c16
commit 40b8cb6e3c
15 changed files with 5631 additions and 9 deletions
+246 -1
View File
@@ -1,6 +1,15 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ArrowLeft, ArrowRight, Document, Download } from '@element-plus/icons-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'
@@ -23,6 +32,10 @@ const isManager = computed(() =>
)
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[]>([])
@@ -494,6 +507,132 @@ async function exportPdf() {
}
}
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 apiBase = new URL(
configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`,
window.location.origin,
)
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() })
@@ -669,6 +808,9 @@ onMounted(async () => {
</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>
@@ -820,6 +962,88 @@ onMounted(async () => {
/>
</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>
@@ -845,6 +1069,23 @@ onMounted(async () => {
.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; }
@@ -919,6 +1160,10 @@ onMounted(async () => {
.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); }