Some checks failed
continuous-integration/drone/push Build is failing
1. 奖章条件优化 - 修复统计查询 SQL 语法 - 添加按类别检查奖章方法 2. 移动端适配 - 登录页、课程中心、课程详情 - 考试页面、成长路径、排行榜 3. 证书系统 - 数据库模型和迁移脚本 - 证书颁发/列表/下载/验证 API - 前端证书列表页面 4. 数据大屏 - 企业级/团队级数据 API - ECharts 可视化大屏页面
1475 lines
41 KiB
Vue
1475 lines
41 KiB
Vue
<template>
|
||
<div class="course-detail-container">
|
||
<!-- 课程头部 -->
|
||
<div class="course-header card">
|
||
<div class="header-content">
|
||
<div class="breadcrumb">
|
||
<el-breadcrumb separator="/">
|
||
<el-breadcrumb-item :to="{ path: '/trainee/course-center' }">
|
||
课程中心
|
||
</el-breadcrumb-item>
|
||
<el-breadcrumb-item>{{ courseInfo.title }}</el-breadcrumb-item>
|
||
</el-breadcrumb>
|
||
</div>
|
||
|
||
<h1 class="course-title">{{ courseInfo.title }}</h1>
|
||
<p class="course-desc">{{ courseInfo.description }}</p>
|
||
|
||
<div class="course-meta">
|
||
<span class="meta-item">
|
||
<el-icon><Clock /></el-icon>
|
||
时长:{{ courseInfo.duration }}
|
||
</span>
|
||
<span class="meta-item">
|
||
<el-icon><Document /></el-icon>
|
||
资料:{{ materials.length }} 个
|
||
</span>
|
||
<span class="meta-item">
|
||
<el-icon><View /></el-icon>
|
||
学习人数:{{ courseInfo.students }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="progress-section">
|
||
<div class="progress-info">
|
||
<span>学习进度</span>
|
||
<span class="progress-value">{{ courseInfo.progress }}%</span>
|
||
</div>
|
||
<el-progress :percentage="courseInfo.progress" :stroke-width="10" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 课程内容 -->
|
||
<div class="course-content">
|
||
<!-- 侧边栏:资料列表 -->
|
||
<div class="content-sidebar card">
|
||
<div class="sidebar-header">
|
||
<h3 class="sidebar-title">学习资料</h3>
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索资料"
|
||
size="small"
|
||
clearable
|
||
style="width: 180px"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
|
||
<!-- 文件类型筛选 -->
|
||
<div class="file-type-filter">
|
||
<el-radio-group v-model="fileTypeFilter" size="small">
|
||
<el-radio-button label="">全部</el-radio-button>
|
||
<el-radio-button label="document">文档</el-radio-button>
|
||
<el-radio-button label="video">视频</el-radio-button>
|
||
<el-radio-button label="audio">音频</el-radio-button>
|
||
<el-radio-button label="image">图片</el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
|
||
<div class="material-list" v-loading="loadingMaterials">
|
||
<div
|
||
class="material-item"
|
||
:class="{ active: currentMaterial?.id === material.id }"
|
||
v-for="material in filteredMaterials"
|
||
:key="material.id"
|
||
@click="selectMaterial(material)"
|
||
>
|
||
<el-icon class="material-icon" :size="20">
|
||
<component :is="getMaterialIcon(material.name)" />
|
||
</el-icon>
|
||
<div class="material-info">
|
||
<div class="material-name" :title="material.name">{{ material.name }}</div>
|
||
<div class="material-meta">
|
||
<span>{{ formatFileSize(material.file_size) }}</span>
|
||
<span>{{ getFileType(material.name) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-empty
|
||
v-if="filteredMaterials.length === 0 && !loadingMaterials"
|
||
description="暂无资料"
|
||
:image-size="80"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区:预览区域 -->
|
||
<div class="content-main card">
|
||
<div v-if="!currentMaterial" class="empty-state">
|
||
<el-icon :size="64" color="#909399"><Document /></el-icon>
|
||
<p>请从左侧选择学习资料</p>
|
||
</div>
|
||
|
||
<div v-else class="preview-container" v-loading="loadingPreview" ref="previewContainerRef" :class="{ 'is-fullscreen': isFullscreen }">
|
||
<!-- 工具栏 -->
|
||
<div class="preview-toolbar">
|
||
<div class="toolbar-left">
|
||
<h2 class="preview-title">{{ currentMaterial.name }}</h2>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<el-button v-if="courseInfo.allow_download" @click="downloadCurrent" :icon="Download">
|
||
下载
|
||
</el-button>
|
||
<el-button @click="toggleFullscreen" v-if="canFullscreen" :icon="isFullscreen ? Close : FullScreen">
|
||
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 预览内容 -->
|
||
<div class="preview-content" ref="previewContentRef">
|
||
<!-- PDF预览 -->
|
||
<div v-if="previewInfo?.preview_type === 'pdf'" class="pdf-viewer-container">
|
||
<div class="pdf-toolbar">
|
||
<div class="page-controls">
|
||
<el-button :icon="ArrowLeft" size="small" :disabled="pdfPage <= 1" @click="pdfPage--" />
|
||
<span class="page-info" v-if="pdfPages > 0">{{ pdfPage }} / {{ pdfPages }}</span>
|
||
<el-button :icon="ArrowRight" size="small" :disabled="pdfPage >= pdfPages" @click="pdfPage++" />
|
||
</div>
|
||
<div class="zoom-controls">
|
||
<el-button :icon="ZoomOut" size="small" @click="pdfScale = Math.max(0.5, pdfScale - 0.25)" />
|
||
<span class="zoom-info">{{ Math.round(pdfScale * 100) }}%</span>
|
||
<el-button :icon="ZoomIn" size="small" @click="pdfScale = Math.min(3.0, pdfScale + 0.25)" />
|
||
</div>
|
||
</div>
|
||
<div class="pdf-wrapper" ref="pdfWrapperRef">
|
||
<div class="pdf-scale-wrapper" :style="pdfScaleStyle">
|
||
<vue-pdf-embed
|
||
:source="getPreviewUrl(previewInfo.preview_url)"
|
||
:page="pdfPage"
|
||
:width="pdfDisplayWidth"
|
||
@loaded="handlePdfLoad"
|
||
@loading-failed="handlePdfError"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频预览 -->
|
||
<div v-else-if="previewInfo?.preview_type === 'video'" class="video-viewer">
|
||
<video
|
||
controls
|
||
width="100%"
|
||
:src="getPreviewUrl(previewInfo.preview_url)"
|
||
>
|
||
您的浏览器不支持视频播放
|
||
</video>
|
||
</div>
|
||
|
||
<!-- 音频预览 -->
|
||
<div v-else-if="previewInfo?.preview_type === 'audio'" class="audio-viewer">
|
||
<div class="audio-wrapper">
|
||
<el-icon :size="80" color="#409eff"><Headset /></el-icon>
|
||
<h3>{{ currentMaterial.name }}</h3>
|
||
<audio
|
||
controls
|
||
:src="getPreviewUrl(previewInfo.preview_url)"
|
||
style="width: 100%; max-width: 600px; margin-top: 20px;"
|
||
>
|
||
您的浏览器不支持音频播放
|
||
</audio>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图片预览 -->
|
||
<div v-else-if="previewInfo?.preview_type === 'image'" class="image-viewer">
|
||
<el-image
|
||
:src="getPreviewUrl(previewInfo.preview_url)"
|
||
:preview-src-list="[getPreviewUrl(previewInfo.preview_url)]"
|
||
fit="contain"
|
||
style="width: 100%; height: 100%;"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Markdown 预览 -->
|
||
<div v-else-if="previewInfo?.preview_type === 'text' && isMarkdownFile(currentMaterial?.name)" class="markdown-viewer">
|
||
<div class="markdown-content" v-html="renderedMarkdown"></div>
|
||
</div>
|
||
|
||
<!-- 文本预览 -->
|
||
<div v-else-if="previewInfo?.preview_type === 'text'" class="text-viewer">
|
||
<div class="text-content">
|
||
<pre>{{ previewInfo.content }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HTML预览(Excel等) -->
|
||
<div v-else-if="previewInfo?.preview_type === 'html' && previewInfo?.is_converted" class="html-viewer">
|
||
<iframe
|
||
:src="getPreviewUrl(previewInfo.preview_url)"
|
||
frameborder="0"
|
||
allowfullscreen
|
||
class="html-iframe"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 下载模式 -->
|
||
<div v-else-if="previewInfo?.preview_type === 'download'" class="download-viewer">
|
||
<div class="download-wrapper">
|
||
<el-icon :size="80" color="#909399"><Download /></el-icon>
|
||
<h3>{{ currentMaterial.name }}</h3>
|
||
<p>此文件暂不支持在线预览</p>
|
||
<el-button v-if="courseInfo.allow_download" type="primary" size="large" @click="downloadCurrent" :icon="Download">
|
||
下载文件
|
||
</el-button>
|
||
<el-tag v-else type="info" size="large">该课程不允许下载资料</el-tag>
|
||
<div class="file-info">
|
||
<span>文件大小:{{ formatFileSize(currentMaterial.file_size) }}</span>
|
||
<span>文件类型:{{ getFileType(currentMaterial.name) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 转换中提示 -->
|
||
<div v-if="isConverting" class="converting-hint">
|
||
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
|
||
<span>正在转换文档,请稍候...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
Clock, Document, View, Search, Download,
|
||
Headset, Loading, VideoCamera, Picture,
|
||
ArrowLeft, ArrowRight, ZoomIn, ZoomOut, Close, FullScreen
|
||
} from '@element-plus/icons-vue'
|
||
import VuePdfEmbed from 'vue-pdf-embed'
|
||
import * as pdfjsLib from 'pdfjs-dist'
|
||
|
||
// 使用 Vite 的 ?url 导入方式获取 worker 文件的 URL
|
||
import pdfWorker from 'pdfjs-dist/build/pdf.worker?url'
|
||
import { getCourseDetail } from '@/api/trainee'
|
||
import { getMaterials, getPreview, downloadFile } from '@/api/material'
|
||
import type { Material, PreviewInfo } from '@/types/material'
|
||
import { formatFileSize, getFileCategory, getFileExtension } from '@/types/material'
|
||
import MarkdownIt from 'markdown-it'
|
||
import DOMPurify from 'dompurify'
|
||
|
||
// 配置 PDF.js worker
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||
|
||
// 解决 pdfjs 字体加载导致的 OTS parsing error
|
||
// 使用本地化资源(/public/pdfjs/),避免依赖国外 CDN
|
||
const CMAP_URL = '/pdfjs/cmaps/'
|
||
const STANDARD_FONT_DATA_URL = '/pdfjs/standard_fonts/'
|
||
|
||
pdfjsLib.GlobalWorkerOptions.cMapUrl = CMAP_URL
|
||
pdfjsLib.GlobalWorkerOptions.cMapPacked = true
|
||
pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = STANDARD_FONT_DATA_URL
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const courseId = computed(() => route.query.id as string)
|
||
|
||
// 状态定义
|
||
const courseInfo = ref<any>({})
|
||
const materials = ref<Material[]>([])
|
||
const currentMaterial = ref<Material | null>(null)
|
||
const previewInfo = ref<PreviewInfo | null>(null)
|
||
const loadingMaterials = ref(false)
|
||
const loadingPreview = ref(false)
|
||
const searchKeyword = ref('')
|
||
const fileTypeFilter = ref('')
|
||
const isConverting = ref(false)
|
||
const isFullscreen = ref(false)
|
||
|
||
// PDF 相关状态
|
||
const pdfPage = ref(1)
|
||
const pdfPages = ref(0)
|
||
const pdfScale = ref(1.0)
|
||
const pdfLoading = ref(true)
|
||
const pdfContainerWidth = ref(0)
|
||
const pdfWrapperRef = ref<HTMLElement>()
|
||
|
||
// 预览容器引用
|
||
const previewContentRef = ref<HTMLElement>()
|
||
const previewContainerRef = ref<HTMLElement>()
|
||
|
||
// 设备像素比(用于高清屏幕渲染)
|
||
const devicePixelRatio = ref(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1)
|
||
|
||
// PDF 渲染缩放比(用于高清渲染)
|
||
const pdfRenderScale = computed(() => {
|
||
// 使用设备像素比,最小1.5倍,最大3倍(避免内存问题)
|
||
return Math.min(Math.max(devicePixelRatio.value, 1.5), 3)
|
||
})
|
||
|
||
// PDF 显示宽度(考虑设备像素比以解决手机端模糊问题)
|
||
const pdfDisplayWidth = computed(() => {
|
||
if (!pdfContainerWidth.value) return undefined
|
||
// 基础宽度 = 容器宽度 - 内边距
|
||
const baseWidth = (pdfContainerWidth.value - 40) * pdfScale.value
|
||
// 乘以渲染缩放比,提高渲染分辨率
|
||
return baseWidth * pdfRenderScale.value
|
||
})
|
||
|
||
// PDF 缩放样式(将高分辨率渲染缩放回正常显示尺寸)
|
||
const pdfScaleStyle = computed(() => {
|
||
const scale = 1 / pdfRenderScale.value
|
||
return {
|
||
transform: `scale(${scale})`,
|
||
transformOrigin: 'top center'
|
||
}
|
||
})
|
||
|
||
// Markdown 渲染
|
||
const md = new MarkdownIt({
|
||
html: true,
|
||
linkify: true,
|
||
typographer: true
|
||
})
|
||
|
||
const renderedMarkdown = computed(() => {
|
||
if (previewInfo.value?.preview_type === 'text' && isMarkdownFile(currentMaterial.value?.name)) {
|
||
return DOMPurify.sanitize(md.render(previewInfo.value.content || ''))
|
||
}
|
||
return ''
|
||
})
|
||
|
||
// 过滤后的资料列表
|
||
const filteredMaterials = computed(() => {
|
||
let result = materials.value
|
||
|
||
if (searchKeyword.value) {
|
||
const keyword = searchKeyword.value.toLowerCase()
|
||
result = result.filter(m => m.name.toLowerCase().includes(keyword))
|
||
}
|
||
|
||
if (fileTypeFilter.value) {
|
||
result = result.filter(m => getFileCategory(m.name) === fileTypeFilter.value)
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
// 全屏相关 - 支持所有预览类型(除了下载模式)
|
||
const canFullscreen = computed(() => {
|
||
return previewInfo.value && previewInfo.value.preview_type !== 'download'
|
||
})
|
||
|
||
// 图标映射
|
||
const getMaterialIcon = (fileName: string) => {
|
||
const type = getFileCategory(fileName)
|
||
switch (type) {
|
||
case 'document': return Document
|
||
case 'video': return VideoCamera
|
||
case 'audio': return Headset
|
||
case 'image': return Picture
|
||
default: return Document
|
||
}
|
||
}
|
||
|
||
// 获取文件类型显示名称
|
||
const getFileType = (fileName: string) => {
|
||
const ext = getFileExtension(fileName).toUpperCase()
|
||
return ext || '未知'
|
||
}
|
||
|
||
// 是否为 Markdown 文件
|
||
const isMarkdownFile = (fileName?: string) => {
|
||
if (!fileName) return false
|
||
const ext = getFileExtension(fileName).toLowerCase()
|
||
return ['md', 'markdown'].includes(ext)
|
||
}
|
||
|
||
// 获取预览 URL
|
||
const getPreviewUrl = (url?: string) => {
|
||
if (!url) return ''
|
||
return url
|
||
}
|
||
|
||
// 加载课程信息
|
||
const loadCourseInfo = async () => {
|
||
try {
|
||
const res: any = await getCourseDetail(courseId.value)
|
||
if (res.code === 200) {
|
||
courseInfo.value = res.data
|
||
}
|
||
} catch (error) {
|
||
console.error('获取课程详情失败:', error)
|
||
ElMessage.error('获取课程详情失败')
|
||
}
|
||
}
|
||
|
||
// 加载资料列表
|
||
const loadMaterials = async () => {
|
||
loadingMaterials.value = true
|
||
try {
|
||
const res: any = await getMaterials(courseId.value)
|
||
if (res.code === 200) {
|
||
materials.value = res.data
|
||
// 如果有资料,默认选中第一个
|
||
if (materials.value.length > 0) {
|
||
selectMaterial(materials.value[0])
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取资料列表失败:', error)
|
||
ElMessage.error('获取资料列表失败')
|
||
} finally {
|
||
loadingMaterials.value = false
|
||
}
|
||
}
|
||
|
||
// 选择资料
|
||
const selectMaterial = async (material: Material) => {
|
||
if (currentMaterial.value?.id === material.id) return
|
||
|
||
currentMaterial.value = material
|
||
loadingPreview.value = true
|
||
pdfScale.value = 1.0
|
||
pdfPage.value = 1
|
||
|
||
try {
|
||
const res: any = await getPreview(material.id)
|
||
if (res.code === 200) {
|
||
previewInfo.value = res.data
|
||
// 如果是 PDF,等待 DOM 更新后计算宽度
|
||
if (previewInfo.value?.preview_type === 'pdf') {
|
||
setTimeout(debouncedUpdatePdfContainerWidth, 100)
|
||
}
|
||
} else if (res.code === 202) {
|
||
isConverting.value = true
|
||
// 轮询检查转换状态
|
||
checkConversionStatus(material.id)
|
||
}
|
||
} catch (error) {
|
||
console.error('获取预览信息失败:', error)
|
||
ElMessage.error('获取预览信息失败')
|
||
} finally {
|
||
loadingPreview.value = false
|
||
}
|
||
}
|
||
|
||
// 检查转换状态(简单模拟)
|
||
const checkConversionStatus = (materialId: string) => {
|
||
const timer = setInterval(async () => {
|
||
if (currentMaterial.value?.id !== materialId) {
|
||
clearInterval(timer)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res: any = await getPreview(materialId)
|
||
if (res.code === 200) {
|
||
clearInterval(timer)
|
||
isConverting.value = false
|
||
previewInfo.value = res.data
|
||
}
|
||
} catch (e) {
|
||
clearInterval(timer)
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
// 下载文件
|
||
const downloadCurrent = () => {
|
||
if (!currentMaterial.value) return
|
||
downloadFile(currentMaterial.value.id, currentMaterial.value.name)
|
||
}
|
||
|
||
// 全屏切换
|
||
const toggleFullscreen = () => {
|
||
if (isFullscreen.value) {
|
||
exitFullscreen()
|
||
} else {
|
||
enterFullscreen()
|
||
}
|
||
}
|
||
|
||
// 进入全屏
|
||
const enterFullscreen = () => {
|
||
const el = previewContainerRef.value as any
|
||
if (!el) return
|
||
|
||
// 尝试原生全屏
|
||
if (el.requestFullscreen) {
|
||
el.requestFullscreen()
|
||
} else if (el.webkitRequestFullscreen) { /* Safari */
|
||
el.webkitRequestFullscreen()
|
||
} else if (el.msRequestFullscreen) { /* IE11 */
|
||
el.msRequestFullscreen()
|
||
} else {
|
||
// 降级方案:CSS全屏
|
||
isFullscreen.value = true
|
||
// PDF需要在布局变化后重新计算宽度
|
||
setTimeout(debouncedUpdatePdfContainerWidth, 100)
|
||
}
|
||
}
|
||
|
||
// 退出全屏
|
||
const exitFullscreen = () => {
|
||
// 尝试退出原生全屏
|
||
if (document.fullscreenElement || (document as any).webkitFullscreenElement || (document as any).msFullscreenElement) {
|
||
if (document.exitFullscreen) {
|
||
document.exitFullscreen()
|
||
} else if ((document as any).webkitExitFullscreen) {
|
||
(document as any).webkitExitFullscreen()
|
||
} else if ((document as any).msExitFullscreen) {
|
||
(document as any).msExitFullscreen()
|
||
}
|
||
} else {
|
||
// 退出CSS全屏
|
||
isFullscreen.value = false
|
||
// PDF需要在布局变化后重新计算宽度
|
||
setTimeout(debouncedUpdatePdfContainerWidth, 100)
|
||
}
|
||
}
|
||
|
||
// 监听原生全屏变化
|
||
const handleFullscreenChange = () => {
|
||
const isNativeFullscreen = !!(document.fullscreenElement || (document as any).webkitFullscreenElement || (document as any).msFullscreenElement)
|
||
isFullscreen.value = isNativeFullscreen
|
||
// 状态变化时更新PDF宽度
|
||
setTimeout(debouncedUpdatePdfContainerWidth, 100)
|
||
}
|
||
|
||
// PDF 相关方法
|
||
const updatePdfContainerWidth = () => {
|
||
if (pdfWrapperRef.value) {
|
||
pdfContainerWidth.value = pdfWrapperRef.value.clientWidth
|
||
}
|
||
}
|
||
|
||
// 防抖函数
|
||
const debounce = (func: Function, delay: number) => {
|
||
let timeout: ReturnType<typeof setTimeout>
|
||
return function(this: any, ...args: any[]) {
|
||
const context = this
|
||
clearTimeout(timeout)
|
||
timeout = setTimeout(() => func.apply(context, args), delay)
|
||
}
|
||
}
|
||
|
||
const debouncedUpdatePdfContainerWidth = debounce(updatePdfContainerWidth, 200)
|
||
|
||
const handlePdfLoad = (pdfDoc: any) => {
|
||
pdfLoading.value = false
|
||
pdfPages.value = pdfDoc.numPages
|
||
}
|
||
|
||
const handlePdfError = (error: any) => {
|
||
console.error('PDF加载失败:', error)
|
||
ElMessage.error('PDF加载失败,请尝试下载查看')
|
||
pdfLoading.value = false
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (!courseId.value) {
|
||
ElMessage.error('课程ID无效')
|
||
router.push('/trainee/course-center')
|
||
return
|
||
}
|
||
|
||
loadCourseInfo()
|
||
loadMaterials()
|
||
|
||
// 添加resize监听
|
||
window.addEventListener('resize', debouncedUpdatePdfContainerWidth)
|
||
|
||
// 添加全屏监听
|
||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
|
||
document.addEventListener('mozfullscreenchange', handleFullscreenChange)
|
||
document.addEventListener('MSFullscreenChange', handleFullscreenChange)
|
||
|
||
setTimeout(debouncedUpdatePdfContainerWidth, 500) // 增加延时确保DOM就绪
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', debouncedUpdatePdfContainerWidth)
|
||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
|
||
document.removeEventListener('mozfullscreenchange', handleFullscreenChange)
|
||
document.removeEventListener('MSFullscreenChange', handleFullscreenChange)
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.course-detail-container {
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
min-height: calc(100vh - 60px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.card {
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.course-header {
|
||
padding: 24px;
|
||
margin-bottom: 20px;
|
||
|
||
.header-content {
|
||
.breadcrumb {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.course-title {
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
margin: 0 0 12px 0;
|
||
}
|
||
|
||
.course-desc {
|
||
color: #606266;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.course-meta {
|
||
display: flex;
|
||
gap: 24px;
|
||
margin-bottom: 24px;
|
||
|
||
.meta-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #606266;
|
||
font-size: 14px;
|
||
|
||
.el-icon {
|
||
color: #409eff;
|
||
}
|
||
}
|
||
}
|
||
|
||
.progress-section {
|
||
.progress-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
color: #606266;
|
||
|
||
.progress-value {
|
||
font-weight: 600;
|
||
color: #409eff;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.course-content {
|
||
display: flex;
|
||
gap: 20px;
|
||
flex: 1;
|
||
min-height: 600px;
|
||
|
||
.content-sidebar {
|
||
width: 320px;
|
||
flex-shrink: 0;
|
||
padding: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: calc(100vh - 280px);
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
|
||
.sidebar-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.file-type-filter {
|
||
margin-bottom: 16px;
|
||
|
||
:deep(.el-radio-group) {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
|
||
.el-radio-button {
|
||
margin: 0;
|
||
}
|
||
|
||
.el-radio-button__inner {
|
||
padding: 6px 12px;
|
||
font-size: 12px;
|
||
border-radius: 6px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.material-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
|
||
.material-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
margin-bottom: 8px;
|
||
|
||
&:hover {
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
&.active {
|
||
background: rgba(64, 158, 255, 0.1);
|
||
border-left: 3px solid #409eff;
|
||
}
|
||
|
||
.material-icon {
|
||
color: #409eff;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.material-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
.material-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #333;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.material-meta {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.content-main {
|
||
flex: 1;
|
||
padding: 20px;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: calc(100vh - 280px);
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 500px;
|
||
color: #909399;
|
||
|
||
p {
|
||
margin-top: 16px;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.preview-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
height: 100%;
|
||
min-height: 600px;
|
||
|
||
// CSS全屏模式样式
|
||
&.is-fullscreen {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
z-index: 2000;
|
||
background: white;
|
||
margin: 0;
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
border-radius: 0;
|
||
|
||
.preview-toolbar {
|
||
padding-bottom: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.preview-content {
|
||
min-height: 0;
|
||
height: auto;
|
||
flex: 1;
|
||
overflow: auto;
|
||
|
||
.pdf-viewer-container {
|
||
height: 100%;
|
||
|
||
.pdf-wrapper {
|
||
height: calc(100% - 50px);
|
||
}
|
||
}
|
||
|
||
.video-viewer,
|
||
.image-viewer,
|
||
.html-viewer {
|
||
height: 100%;
|
||
|
||
video,
|
||
iframe,
|
||
.html-iframe {
|
||
height: 100%;
|
||
min-height: auto;
|
||
}
|
||
}
|
||
|
||
.markdown-viewer,
|
||
.text-viewer {
|
||
height: 100%;
|
||
|
||
.markdown-content,
|
||
.text-content {
|
||
height: 100%;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.preview-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
margin-bottom: 20px;
|
||
|
||
.toolbar-left {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
.preview-title {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
}
|
||
|
||
.preview-content {
|
||
flex: 1;
|
||
position: relative;
|
||
min-height: 500px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.pdf-viewer-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
|
||
.pdf-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 10px 20px;
|
||
background: #fff;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
flex-shrink: 0;
|
||
|
||
.page-controls,
|
||
.zoom-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
|
||
span {
|
||
font-size: 14px;
|
||
color: #606266;
|
||
min-width: 60px;
|
||
text-align: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
.pdf-wrapper {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 20px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
|
||
// 高清渲染缩放容器
|
||
.pdf-scale-wrapper {
|
||
display: flex;
|
||
justify-content: center;
|
||
|
||
:deep(.vue-pdf-embed) {
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||
background: white;
|
||
// 确保canvas渲染清晰
|
||
image-rendering: -webkit-optimize-contrast;
|
||
image-rendering: crisp-edges;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.video-viewer,
|
||
.image-viewer {
|
||
width: 100%;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 500px;
|
||
|
||
iframe,
|
||
video {
|
||
border-radius: 8px;
|
||
flex: 1;
|
||
}
|
||
}
|
||
|
||
.html-viewer {
|
||
width: 100%;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 600px;
|
||
|
||
.html-iframe {
|
||
width: 100%;
|
||
flex: 1;
|
||
// 使用视口高度计算,确保 iframe 有足够高度显示内容
|
||
// 减去:导航栏(60px) + 课程头部(约180px) + 工具栏(约80px) + padding(40px)
|
||
height: calc(100vh - 360px);
|
||
min-height: 600px;
|
||
border-radius: 8px;
|
||
background: white;
|
||
}
|
||
}
|
||
|
||
.audio-viewer,
|
||
.download-viewer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 1;
|
||
|
||
.audio-wrapper,
|
||
.download-wrapper {
|
||
text-align: center;
|
||
padding: 40px;
|
||
|
||
h3 {
|
||
margin: 20px 0;
|
||
color: #333;
|
||
font-size: 18px;
|
||
}
|
||
|
||
p {
|
||
color: #909399;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.file-info {
|
||
margin-top: 20px;
|
||
display: flex;
|
||
gap: 24px;
|
||
justify-content: center;
|
||
color: #909399;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.markdown-viewer {
|
||
width: 100%;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.markdown-content {
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 30px;
|
||
flex: 1;
|
||
overflow: auto;
|
||
|
||
// Markdown 基础样式
|
||
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
||
margin-top: 24px;
|
||
margin-bottom: 16px;
|
||
font-weight: 600;
|
||
line-height: 1.25;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
:deep(h1) {
|
||
font-size: 2em;
|
||
padding-bottom: 0.3em;
|
||
border-bottom: 1px solid #eaecef;
|
||
}
|
||
|
||
:deep(h2) {
|
||
font-size: 1.5em;
|
||
padding-bottom: 0.3em;
|
||
border-bottom: 1px solid #eaecef;
|
||
}
|
||
|
||
:deep(h3) { font-size: 1.25em; }
|
||
:deep(h4) { font-size: 1em; }
|
||
:deep(h5) { font-size: 0.875em; }
|
||
:deep(h6) { font-size: 0.85em; color: #6a737d; }
|
||
|
||
:deep(p) {
|
||
margin-top: 0;
|
||
margin-bottom: 16px;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
}
|
||
|
||
:deep(a) {
|
||
color: #409eff;
|
||
text-decoration: none;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
|
||
:deep(ul), :deep(ol) {
|
||
margin-top: 0;
|
||
margin-bottom: 16px;
|
||
padding-left: 2em;
|
||
}
|
||
|
||
:deep(li) {
|
||
margin-top: 0.25em;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
:deep(blockquote) {
|
||
margin: 16px 0;
|
||
padding: 0 1em;
|
||
color: #6a737d;
|
||
border-left: 4px solid #dfe2e5;
|
||
|
||
p {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
:deep(code) {
|
||
padding: 0.2em 0.4em;
|
||
margin: 0;
|
||
font-size: 85%;
|
||
background-color: rgba(27, 31, 35, 0.05);
|
||
border-radius: 3px;
|
||
font-family: 'Courier New', monospace;
|
||
}
|
||
|
||
:deep(pre) {
|
||
padding: 16px;
|
||
overflow: auto;
|
||
font-size: 85%;
|
||
line-height: 1.45;
|
||
background-color: #f6f8fa;
|
||
border-radius: 6px;
|
||
margin-bottom: 16px;
|
||
|
||
code {
|
||
padding: 0;
|
||
background-color: transparent;
|
||
}
|
||
}
|
||
|
||
:deep(table) {
|
||
border-spacing: 0;
|
||
border-collapse: collapse;
|
||
margin-bottom: 16px;
|
||
width: 100%;
|
||
overflow: auto;
|
||
|
||
th, td {
|
||
padding: 6px 13px;
|
||
border: 1px solid #dfe2e5;
|
||
}
|
||
|
||
th {
|
||
font-weight: 600;
|
||
background-color: #f6f8fa;
|
||
}
|
||
|
||
tr {
|
||
background-color: white;
|
||
border-top: 1px solid #c6cbd1;
|
||
|
||
&:nth-child(2n) {
|
||
background-color: #f6f8fa;
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(hr) {
|
||
height: 0.25em;
|
||
padding: 0;
|
||
margin: 24px 0;
|
||
background-color: #e1e4e8;
|
||
border: 0;
|
||
}
|
||
|
||
:deep(img) {
|
||
max-width: 100%;
|
||
box-sizing: content-box;
|
||
background-color: white;
|
||
}
|
||
|
||
:deep(strong) {
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(em) {
|
||
font-style: italic;
|
||
}
|
||
}
|
||
}
|
||
|
||
.text-viewer {
|
||
width: 100%;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.text-content {
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
flex: 1;
|
||
overflow: auto;
|
||
|
||
pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
}
|
||
}
|
||
}
|
||
|
||
.converting-hint {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 20px 30px;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
font-size: 16px;
|
||
color: #409eff;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 响应式设计
|
||
@media (max-width: 1200px) {
|
||
.course-detail-container {
|
||
.course-content {
|
||
flex-direction: column;
|
||
|
||
.content-sidebar {
|
||
width: 100%;
|
||
max-height: 300px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.course-detail-container {
|
||
padding: 10px;
|
||
|
||
.course-header {
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
|
||
.header-content {
|
||
.course-title {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.course-meta {
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.course-content {
|
||
gap: 12px;
|
||
|
||
.content-sidebar {
|
||
padding: 12px;
|
||
|
||
.sidebar-header {
|
||
margin-bottom: 12px;
|
||
}
|
||
}
|
||
|
||
.content-main {
|
||
padding: 12px;
|
||
|
||
.preview-container {
|
||
min-height: 400px;
|
||
|
||
.preview-toolbar {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
|
||
.toolbar-left {
|
||
width: 100%;
|
||
}
|
||
|
||
.toolbar-right {
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
|
||
.preview-content {
|
||
.pdf-viewer-container {
|
||
.pdf-wrapper {
|
||
padding: 10px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 手机端深度优化
|
||
@media (max-width: 480px) {
|
||
.course-detail-container {
|
||
padding: 8px;
|
||
|
||
.course-header {
|
||
padding: 14px;
|
||
border-radius: 12px;
|
||
margin-bottom: 10px;
|
||
|
||
.header-content {
|
||
.breadcrumb {
|
||
margin-bottom: 12px;
|
||
|
||
:deep(.el-breadcrumb) {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.course-title {
|
||
font-size: 18px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.course-desc {
|
||
font-size: 13px;
|
||
-webkit-line-clamp: 2;
|
||
}
|
||
|
||
.course-meta {
|
||
gap: 8px;
|
||
|
||
.meta-item {
|
||
font-size: 12px;
|
||
padding: 4px 8px;
|
||
}
|
||
}
|
||
|
||
.progress-section {
|
||
margin-top: 12px;
|
||
|
||
.progress-info {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.course-content {
|
||
gap: 10px;
|
||
|
||
.content-sidebar {
|
||
padding: 10px;
|
||
max-height: 200px;
|
||
border-radius: 12px;
|
||
|
||
.sidebar-header {
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
|
||
.sidebar-title {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.el-input {
|
||
width: 100% !important;
|
||
}
|
||
}
|
||
|
||
.file-type-filter {
|
||
margin-bottom: 10px;
|
||
overflow-x: auto;
|
||
|
||
.el-radio-group {
|
||
flex-wrap: nowrap;
|
||
|
||
:deep(.el-radio-button__inner) {
|
||
padding: 4px 10px;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.material-list {
|
||
.material-item {
|
||
padding: 8px 10px;
|
||
|
||
.material-info {
|
||
.material-name {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.material-meta {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.content-main {
|
||
padding: 10px;
|
||
border-radius: 12px;
|
||
|
||
.empty-state {
|
||
padding: 40px 20px;
|
||
|
||
p {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.preview-container {
|
||
min-height: 300px;
|
||
|
||
.preview-toolbar {
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
|
||
.toolbar-left {
|
||
.preview-title {
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
.toolbar-right {
|
||
.el-button {
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.preview-content {
|
||
.pdf-viewer-container {
|
||
.pdf-toolbar {
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding: 8px;
|
||
|
||
.page-controls,
|
||
.zoom-controls {
|
||
gap: 4px;
|
||
|
||
.page-info,
|
||
.zoom-info {
|
||
font-size: 12px;
|
||
min-width: 50px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 视频自适应
|
||
.video-player {
|
||
video {
|
||
max-height: 50vh;
|
||
}
|
||
}
|
||
|
||
// 图片自适应
|
||
.image-preview {
|
||
img {
|
||
max-height: 60vh;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|