课表优化

This commit is contained in:
2026-08-11 16:56:23 +08:00 Unverified
parent e8482b26f2
commit cd4ffa1d85
7 changed files with 569 additions and 34 deletions
+16
View File
@@ -28,6 +28,22 @@ export async function downloadApiFile(
URL.revokeObjectURL(url)
}
export async function downloadApiPostFile(
path: string,
payload: unknown,
fallbackName: string,
) {
const response = await http.post(path, payload, { responseType: 'blob' })
const url = URL.createObjectURL(response.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = responseFileName(response.headers['content-disposition'], fallbackName)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
export async function importExcel(path: string, file: File) {
const form = new FormData()
form.append('file', file)
+131 -30
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import {
ArrowLeft,
ArrowRight,
@@ -12,7 +12,7 @@ import {
} from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
import { downloadApiFile, downloadApiPostFile } from '../api/excel'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
@@ -32,6 +32,8 @@ const isManager = computed(() =>
)
const loading = ref(false)
const exportingPdf = ref(false)
const batchExporting = ref(false)
const batchResourceIds = ref<string[]>([])
const calendarDialogVisible = ref(false)
const calendarLoading = ref(false)
const calendarActionLoading = ref(false)
@@ -61,7 +63,6 @@ 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> = {
@@ -104,6 +105,11 @@ const selectedResourceId = computed(() => {
if (resourceType.value === 'Classroom') return classroomId.value
return classId.value
})
const batchResources = computed(() => {
if (resourceType.value === 'Teacher') return filteredTeachers.value
if (resourceType.value === 'Classroom') return filteredClassrooms.value
return filteredClasses.value
})
const slotMap = computed<Map<number, any>>(() =>
new Map<number, any>(
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
@@ -444,6 +450,7 @@ async function loadManagementOptions() {
}
async function onTermChanged() {
batchResourceIds.value = []
if (isManager.value) await loadManagementOptions()
await loadTimetable()
}
@@ -472,6 +479,7 @@ function onBuildingChanged() {
}
function onResourceTypeChanged() {
batchResourceIds.value = []
collegeId.value = ''
majorId.value = ''
grade.value = undefined
@@ -487,6 +495,67 @@ function onResourceTypeChanged() {
}
}
function resourceLabel(item: any) {
if (resourceType.value === 'Teacher') return `${item.teacherNumber} · ${item.name}`
if (resourceType.value === 'Classroom') return `${item.buildingName} · ${item.name}`
return `${item.code} · ${item.name}`
}
function selectFilteredResources() {
if (batchResources.value.length > 100) {
ElMessage.warning('单次最多导出 100 项,请进一步缩小筛选范围。')
return
}
batchResourceIds.value = batchResources.value.map((item: any) => item.id)
}
function batchExportPayload() {
if (!batchResourceIds.value.length) {
ElMessage.warning('请先从筛选结果中选择要导出的课表。')
return null
}
return {
resourceType: resourceType.value,
resourceIds: batchResourceIds.value,
academicTermId: termId.value,
schedulePlanId: planId.value || undefined,
}
}
async function exportBatchExcel() {
const payload = batchExportPayload()
if (!payload) return
batchExporting.value = true
try {
await downloadApiPostFile(
'/timetables/management/export/batch.xlsx',
payload,
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
batchExporting.value = false
}
}
async function exportBatchPdf() {
const payload = batchExportPayload()
if (!payload) return
batchExporting.value = true
try {
await downloadApiPostFile(
'/timetables/management/export/batch.pdf',
payload,
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.pdf`,
)
} catch (error) {
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
} finally {
batchExporting.value = false
}
}
async function loadTimetable() {
if (!termId.value) {
timetable.value = null
@@ -564,35 +633,23 @@ async function exportExcel() {
}
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
if (isMine.value) {
await downloadApiFile(`/timetables/mine/export.pdf?academicTermId=${termId.value}`, '我的课表.pdf')
} else if (isTeacherView.value && teacherIdParam.value) {
await downloadApiFile(`/timetables/teachers/${teacherIdParam.value}/export.pdf?academicTermId=${termId.value}`, '教师课表.pdf')
} 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.pdf?${query}`, '课表.pdf')
} else {
await downloadApiFile(`/timetables/classes/${classId.value}/export.pdf?academicTermId=${termId.value}`, '班级课表.pdf')
}
pdf.save(`${timetable.value.subject?.name ?? '课表'}-${timetable.value.term.name}.pdf`)
} catch (error) {
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
} finally {
@@ -853,6 +910,46 @@ onMounted(async () => {
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
</small>
<div v-if="isManager" class="batch-export-panel">
<div class="batch-export-heading">
<strong>批量导出</strong>
<span>按当前筛选结果多选最多 100 </span>
</div>
<el-select
v-model="batchResourceIds"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
placeholder="选择要批量导出的课表"
>
<el-option
v-for="item in batchResources"
:key="item.id"
:label="resourceLabel(item)"
:value="item.id"
/>
</el-select>
<div class="batch-export-actions">
<el-button size="small" @click="selectFilteredResources">全选筛选结果</el-button>
<el-button size="small" :disabled="!batchResourceIds.length" @click="batchResourceIds = []">清空</el-button>
<span>已选 {{ batchResourceIds.length }} </span>
<el-button
size="small"
type="primary"
:loading="batchExporting"
:disabled="!batchResourceIds.length"
@click="exportBatchExcel"
>批量导出 Excel</el-button>
<el-button
size="small"
:loading="batchExporting"
:disabled="!batchResourceIds.length"
@click="exportBatchPdf"
>批量导出 PDF</el-button>
</div>
</div>
</section>
<section v-loading="loading" class="timetable-sheet">
@@ -910,7 +1007,7 @@ onMounted(async () => {
</div>
</div>
<div v-if="timetable" ref="exportArea" class="timetable-export-area">
<div v-if="timetable" class="timetable-export-area">
<div class="sheet-meta">
<div>
<strong>{{ timetable.subject.name }}</strong>
@@ -1290,6 +1387,10 @@ onMounted(async () => {
.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; }
.batch-export-panel { display: grid; gap: 10px; width: min(760px, 100%); margin-top: 8px; padding: 14px; border: 1px solid #d7e3ea; border-radius: 8px; background: #f8fbfc; }
.batch-export-heading { display: flex; align-items: baseline; gap: 10px; color: #17324d; }
.batch-export-heading span, .batch-export-actions span { color: #718191; font-size: 12px; }
.batch-export-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.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; }