Files
012-kaopeilian/frontend/src/views/trainee/course-detail.vue
yuliang_guo 6f0f2e6363
Some checks failed
continuous-integration/drone/push Build is failing
feat: KPL v1.5.0 功能迭代
1. 奖章条件优化
- 修复统计查询 SQL 语法
- 添加按类别检查奖章方法

2. 移动端适配
- 登录页、课程中心、课程详情
- 考试页面、成长路径、排行榜

3. 证书系统
- 数据库模型和迁移脚本
- 证书颁发/列表/下载/验证 API
- 前端证书列表页面

4. 数据大屏
- 企业级/团队级数据 API
- ECharts 可视化大屏页面
2026-01-29 16:51:17 +08:00

1475 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>