feat(StudyDetailPage): 添加PDF预览功能
引入@sunsetglow/vue-pdf-viewer库,实现在学习详情页面中直接预览PDF文件的功能,提升用户体验。移除原有的PDF文件下载链接,改为点击“查看文件”按钮即可在页面内直接预览PDF内容。
Showing
3 changed files
with
110 additions
and
54 deletions
| ... | @@ -19,6 +19,7 @@ | ... | @@ -19,6 +19,7 @@ |
| 19 | "@fortawesome/free-solid-svg-icons": "^6.5.1", | 19 | "@fortawesome/free-solid-svg-icons": "^6.5.1", |
| 20 | "@fortawesome/vue-fontawesome": "^3.0.5", | 20 | "@fortawesome/vue-fontawesome": "^3.0.5", |
| 21 | "@heroicons/vue": "^2.2.0", | 21 | "@heroicons/vue": "^2.2.0", |
| 22 | + "@sunsetglow/vue-pdf-viewer": "^0.3.0", | ||
| 22 | "@vant/touch-emulator": "^1.4.0", | 23 | "@vant/touch-emulator": "^1.4.0", |
| 23 | "@vant/use": "^1.6.0", | 24 | "@vant/use": "^1.6.0", |
| 24 | "@videojs-player/vue": "^1.0.0", | 25 | "@videojs-player/vue": "^1.0.0", | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2025-04-07 | 2 | + * @Date: 2025-04-07 |
| 3 | - * @Description: 学习详情页面 | 3 | + * @Description: 学习详情页面 |
| 4 | ---> | 4 | + --> |
| 5 | <template> | 5 | <template> |
| 6 | <div class="study-detail-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20"> | 6 | <div class="study-detail-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20"> |
| 7 | <div v-if="course" class="flex flex-col h-screen"> | 7 | <div v-if="course" class="flex flex-col h-screen"> |
| ... | @@ -22,23 +22,21 @@ | ... | @@ -22,23 +22,21 @@ |
| 22 | </div> | 22 | </div> |
| 23 | </div> | 23 | </div> |
| 24 | <!-- 视频播放器 --> | 24 | <!-- 视频播放器 --> |
| 25 | - <VideoPlayer v-show="isPlaying" ref="videoPlayerRef" :video-url="courseFile.list.length ? courseFile['list'][0]['url'] : ''" :autoplay="false" | 25 | + <VideoPlayer v-show="isPlaying" ref="videoPlayerRef" |
| 26 | + :video-url="courseFile.list.length ? courseFile['list'][0]['url'] : ''" :autoplay="false" | ||
| 26 | @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> | 27 | @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> |
| 27 | </div> | 28 | </div> |
| 28 | - <div v-if="course.course_type === 'audio'" class="w-full relative" style="border-bottom: 1px solid #F3F4F6;"> | 29 | + <div v-if="course.course_type === 'audio'" class="w-full relative" |
| 30 | + style="border-bottom: 1px solid #F3F4F6;"> | ||
| 29 | <!-- 音频播放器 --> | 31 | <!-- 音频播放器 --> |
| 30 | <AudioPlayer :songs="audioList" /> | 32 | <AudioPlayer :songs="audioList" /> |
| 31 | </div> | 33 | </div> |
| 32 | <!-- 图片列表展示区域 --> | 34 | <!-- 图片列表展示区域 --> |
| 33 | <div v-if="course.course_type === 'image'" class="w-full relative"> | 35 | <div v-if="course.course_type === 'image'" class="w-full relative"> |
| 34 | <van-swipe class="w-full" :autoplay="0" :show-indicators="true"> | 36 | <van-swipe class="w-full" :autoplay="0" :show-indicators="true"> |
| 35 | - <van-swipe-item v-for="(item, index) in courseFile?.list" :key="index" @click.native="showImagePreview(index)"> | 37 | + <van-swipe-item v-for="(item, index) in courseFile?.list" :key="index" |
| 36 | - <van-image | 38 | + @click.native="showImagePreview(index)"> |
| 37 | - :src="item.url" | 39 | + <van-image :src="item.url" class="w-full" fit="cover" style="aspect-ratio: 16/9;"> |
| 38 | - class="w-full" | ||
| 39 | - fit="cover" | ||
| 40 | - style="aspect-ratio: 16/9;" | ||
| 41 | - > | ||
| 42 | <template #image> | 40 | <template #image> |
| 43 | <img :src="item.url" class="w-full h-full object-cover" /> | 41 | <img :src="item.url" class="w-full h-full object-cover" /> |
| 44 | </template> | 42 | </template> |
| ... | @@ -49,20 +47,14 @@ | ... | @@ -49,20 +47,14 @@ |
| 49 | <!-- 文件列表展示区域 --> | 47 | <!-- 文件列表展示区域 --> |
| 50 | <div v-if="course.course_type === 'file'" class="w-full relative bg-white rounded-lg shadow-sm"> | 48 | <div v-if="course.course_type === 'file'" class="w-full relative bg-white rounded-lg shadow-sm"> |
| 51 | <div class="p-4 space-y-3"> | 49 | <div class="p-4 space-y-3"> |
| 52 | - <div v-for="(item, index) in courseFile?.list" :key="index" class="group hover:bg-gray-50 transition-colors rounded-lg p-3"> | 50 | + <div v-for="(item, index) in courseFile?.list" :key="index" |
| 51 | + class="group hover:bg-gray-50 transition-colors rounded-lg p-3"> | ||
| 53 | <div class="flex items-center justify-between space-x-3 px-2"> | 52 | <div class="flex items-center justify-between space-x-3 px-2"> |
| 54 | <div class="flex items-center space-x-3 flex-1 min-w-0"> | 53 | <div class="flex items-center space-x-3 flex-1 min-w-0"> |
| 55 | <font-awesome-icon icon="file-alt" class="text-gray-400 text-lg flex-shrink-0" /> | 54 | <font-awesome-icon icon="file-alt" class="text-gray-400 text-lg flex-shrink-0" /> |
| 56 | - <h3 class="text-xs font-medium text-gray-900 truncate">{{ item.title }}</h3> | 55 | + <h3 class="text-x font-medium text-gray-900 truncate">{{ item.title }}</h3> |
| 57 | </div> | 56 | </div> |
| 58 | - <template v-if="item.url.toLowerCase().endsWith('.pdf')"> | 57 | + <div class="text-x text-blue-600 hover:text-blue-800 hover:underline whitespace-nowrap" @click="showPdf(item.url)">查看文件</div> |
| 59 | - <a :href="item.url" download class="text-xs text-blue-600 hover:text-blue-800 hover:underline whitespace-nowrap"> | ||
| 60 | - 查看文件 | ||
| 61 | - </a> | ||
| 62 | - </template> | ||
| 63 | - <template v-else> | ||
| 64 | - <a :href="item.url" target="_blank" class="text-xs text-blue-600 hover:text-blue-800 hover:underline whitespace-nowrap">打开文件</a> | ||
| 65 | - </template> | ||
| 66 | </div> | 58 | </div> |
| 67 | </div> | 59 | </div> |
| 68 | </div> | 60 | </div> |
| ... | @@ -102,8 +94,8 @@ | ... | @@ -102,8 +94,8 @@ |
| 102 | <div v-for="comment in commentList" :key="comment.id" | 94 | <div v-for="comment in commentList" :key="comment.id" |
| 103 | class="border-b border-gray-100 last:border-b-0 py-4"> | 95 | class="border-b border-gray-100 last:border-b-0 py-4"> |
| 104 | <div class="flex"> | 96 | <div class="flex"> |
| 105 | - <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" class="w-10 h-10 rounded-full flex-shrink-0" | 97 | + <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" |
| 106 | - style="margin-right: 0.5rem;" /> | 98 | + class="w-10 h-10 rounded-full flex-shrink-0" style="margin-right: 0.5rem;" /> |
| 107 | <div class="flex-1 ml-3"> | 99 | <div class="flex-1 ml-3"> |
| 108 | <div class="flex justify-between items-center mb-1"> | 100 | <div class="flex justify-between items-center mb-1"> |
| 109 | <span class="font-medium text-gray-900">{{ comment.name }}</span> | 101 | <span class="font-medium text-gray-900">{{ comment.name }}</span> |
| ... | @@ -129,23 +121,20 @@ | ... | @@ -129,23 +121,20 @@ |
| 129 | 121 | ||
| 130 | <!-- 可滚动的评论列表 --> | 122 | <!-- 可滚动的评论列表 --> |
| 131 | <div class="flex-1 overflow-y-auto"> | 123 | <div class="flex-1 overflow-y-auto"> |
| 132 | - <van-list | 124 | + <van-list v-model:loading="popupLoading" :finished="popupFinished" |
| 133 | - v-model:loading="popupLoading" | 125 | + finished-text="没有更多评论了" @load="onPopupLoad" class="px-4 py-2 pb-16"> |
| 134 | - :finished="popupFinished" | ||
| 135 | - finished-text="没有更多评论了" | ||
| 136 | - @load="onPopupLoad" | ||
| 137 | - class="px-4 py-2 pb-16" | ||
| 138 | - > | ||
| 139 | <div v-for="comment in popupCommentList" :key="comment.id" | 126 | <div v-for="comment in popupCommentList" :key="comment.id" |
| 140 | class="border-b border-gray-100 last:border-b-0 py-4"> | 127 | class="border-b border-gray-100 last:border-b-0 py-4"> |
| 141 | <div class="flex"> | 128 | <div class="flex"> |
| 142 | - <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" class="w-10 h-10 rounded-full flex-shrink-0" | 129 | + <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" |
| 143 | - style="margin-right: 0.5rem;" /> | 130 | + class="w-10 h-10 rounded-full flex-shrink-0" |
| 131 | + style="margin-right: 0.5rem;" /> | ||
| 144 | <div class="flex-1 ml-3"> | 132 | <div class="flex-1 ml-3"> |
| 145 | <div class="flex justify-between items-center mb-1"> | 133 | <div class="flex justify-between items-center mb-1"> |
| 146 | <span class="font-medium text-gray-900">{{ comment.name }}</span> | 134 | <span class="font-medium text-gray-900">{{ comment.name }}</span> |
| 147 | <div class="flex items-center space-x-1"> | 135 | <div class="flex items-center space-x-1"> |
| 148 | - <span class="text-sm text-gray-500">{{ comment.like_count }}</span> | 136 | + <span class="text-sm text-gray-500">{{ comment.like_count |
| 137 | + }}</span> | ||
| 149 | | 138 | |
| 150 | <van-icon :name="comment.is_like ? 'like' : 'like-o'" | 139 | <van-icon :name="comment.is_like ? 'like' : 'like-o'" |
| 151 | :class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }" | 140 | :class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }" |
| ... | @@ -154,7 +143,8 @@ | ... | @@ -154,7 +143,8 @@ |
| 154 | </div> | 143 | </div> |
| 155 | </div> | 144 | </div> |
| 156 | <p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p> | 145 | <p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p> |
| 157 | - <div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) }}</div> | 146 | + <div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) |
| 147 | + }}</div> | ||
| 158 | </div> | 148 | </div> |
| 159 | </div> | 149 | </div> |
| 160 | </div> | 150 | </div> |
| ... | @@ -196,11 +186,7 @@ | ... | @@ -196,11 +186,7 @@ |
| 196 | </div> | 186 | </div> |
| 197 | 187 | ||
| 198 | <!-- 图片预览组件 --> | 188 | <!-- 图片预览组件 --> |
| 199 | - <van-image-preview | 189 | + <van-image-preview v-model:show="showPreview" :images="previewImages" :close-on-click-image="false"> |
| 200 | - v-model:show="showPreview" | ||
| 201 | - :images="previewImages" | ||
| 202 | - :close-on-click-image="false" | ||
| 203 | - > | ||
| 204 | <template #image="{ src, style, onLoad }"> | 190 | <template #image="{ src, style, onLoad }"> |
| 205 | <img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" /> | 191 | <img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" /> |
| 206 | </template> | 192 | </template> |
| ... | @@ -218,8 +204,7 @@ | ... | @@ -218,8 +204,7 @@ |
| 218 | <!-- 可滚动的目录列表 --> | 204 | <!-- 可滚动的目录列表 --> |
| 219 | <div class="flex-1 overflow-y-auto px-4 py-2"> | 205 | <div class="flex-1 overflow-y-auto px-4 py-2"> |
| 220 | <div v-if="course_lessons.length" class="space-y-4"> | 206 | <div v-if="course_lessons.length" class="space-y-4"> |
| 221 | - <div v-for="(lesson, index) in course_lessons" :key="index" | 207 | + <div v-for="(lesson, index) in course_lessons" :key="index" @click="handleLessonClick(lesson)" |
| 222 | - @click="handleLessonClick(lesson)" | ||
| 223 | class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"> | 208 | class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"> |
| 224 | <div v-if="lesson.progress > 0 && lesson.progress < 100" | 209 | <div v-if="lesson.progress > 0 && lesson.progress < 100" |
| 225 | class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded"> | 210 | class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded"> |
| ... | @@ -238,6 +223,11 @@ | ... | @@ -238,6 +223,11 @@ |
| 238 | </div> | 223 | </div> |
| 239 | </div> | 224 | </div> |
| 240 | </van-popup> | 225 | </van-popup> |
| 226 | + | ||
| 227 | + <!-- PDF预览 --> | ||
| 228 | + <van-popup v-model:show="pdfShow" position="right" closeable :style="{ height: '100%', width: '100%' }"> | ||
| 229 | + <div id="pdf-container"></div> | ||
| 230 | + </van-popup> | ||
| 241 | </div> | 231 | </div> |
| 242 | </template> | 232 | </template> |
| 243 | 233 | ||
| ... | @@ -250,6 +240,9 @@ import AudioPlayer from '@/components/ui/AudioPlayer.vue'; | ... | @@ -250,6 +240,9 @@ import AudioPlayer from '@/components/ui/AudioPlayer.vue'; |
| 250 | import dayjs from 'dayjs'; | 240 | import dayjs from 'dayjs'; |
| 251 | import { formatDate } from '@/utils/tools' | 241 | import { formatDate } from '@/utils/tools' |
| 252 | 242 | ||
| 243 | +import { initPdfView, configPdfApiOptions, configOption } from "@sunsetglow/vue-pdf-viewer"; | ||
| 244 | +import "@sunsetglow/vue-pdf-viewer/dist/style.css"; | ||
| 245 | + | ||
| 253 | // 导入接口 | 246 | // 导入接口 |
| 254 | import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course'; | 247 | import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course'; |
| 255 | 248 | ||
| ... | @@ -343,18 +336,6 @@ const commentCount = ref(0); | ... | @@ -343,18 +336,6 @@ const commentCount = ref(0); |
| 343 | const commentList = ref([]); | 336 | const commentList = ref([]); |
| 344 | const courseFile = ref({}); | 337 | const courseFile = ref({}); |
| 345 | 338 | ||
| 346 | -// 处理课程切换 | ||
| 347 | -// 打开PDF预览 | ||
| 348 | -const openPdfViewer = (item) => { | ||
| 349 | - router.push({ | ||
| 350 | - name: 'pdf-preview', | ||
| 351 | - query: { | ||
| 352 | - url: item.url, | ||
| 353 | - title: item.title | ||
| 354 | - } | ||
| 355 | - }); | ||
| 356 | -}; | ||
| 357 | - | ||
| 358 | const handleLessonClick = async (lesson) => { | 339 | const handleLessonClick = async (lesson) => { |
| 359 | showCatalog.value = false; // 关闭目录弹窗 | 340 | showCatalog.value = false; // 关闭目录弹窗 |
| 360 | isPlaying.value = false; // 重置播放状态 | 341 | isPlaying.value = false; // 重置播放状态 |
| ... | @@ -377,6 +358,70 @@ const handleLessonClick = async (lesson) => { | ... | @@ -377,6 +358,70 @@ const handleLessonClick = async (lesson) => { |
| 377 | } | 358 | } |
| 378 | }; | 359 | }; |
| 379 | 360 | ||
| 361 | +const loading = ref(false); | ||
| 362 | +const pdfPath = new URL("@sunsetglow/vue-pdf-viewer/dist/libs/pdf.worker.min.js", import.meta.url).href; | ||
| 363 | +const pdfShow = ref(false); | ||
| 364 | + | ||
| 365 | +const showPdf = (url) => { | ||
| 366 | + pdfShow.value = true; | ||
| 367 | + // 初始化pdf | ||
| 368 | + setTimeout(() => { | ||
| 369 | + nextTick(() => { | ||
| 370 | + loading.value = true; | ||
| 371 | + initPdfView(document.querySelector("#pdf-container"), { | ||
| 372 | + loadFileUrl: url, //文件路径 | ||
| 373 | + pdfPath: pdfPath, // pdf.js 里需要指定的文件路径 | ||
| 374 | + loading: (load, fileInfo) => { | ||
| 375 | + loading.value = load; | ||
| 376 | + console.log(`pdf 文件总数:${fileInfo.totalPage}`); | ||
| 377 | + //加载完成会返回 false | ||
| 378 | + configPdfApiOptions.onSearch("", false); | ||
| 379 | + }, | ||
| 380 | + pdfOption: { | ||
| 381 | + // search: true, // 搜索 开启搜索必须开启textLayer 为true | ||
| 382 | + scale: true, //缩放 | ||
| 383 | + pdfImageView: false, //pdf 是否可以单片点击预览 | ||
| 384 | + page: true, //分页查看 | ||
| 385 | + navShow: true, //左侧导航 | ||
| 386 | + navigationShow: false, // 左侧导航是否开启 | ||
| 387 | + pdfViewResize: true, // 是否开启resize 函数 确保pdf 根据可视窗口缩放大小 | ||
| 388 | + toolShow: true, // 是否开启顶部导航 | ||
| 389 | + download: true, //下载 | ||
| 390 | + clearScale: 1.5, // 清晰度 默认1.5 感觉不清晰调大 ,当然清晰度越高pdf生成性能有影响 | ||
| 391 | + fileName: "preview.pdf", // pdf 下载文件名称 | ||
| 392 | + lang: "en", //字典语言 | ||
| 393 | + print: true, //打印功能 | ||
| 394 | + customPdfOption: { | ||
| 395 | + // customPdfOption是 pdfjs getDocument 函数中一些配置参数 具体可参考 https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html#~DocumentInitParameters | ||
| 396 | + cMapPacked: true, //指定 CMap 是否是二进制打包的 | ||
| 397 | + cMapUrl: "https://cdn.jsdelivr.net/npm/pdfjs-dist@2.2.228/cmaps/", //预定义 Adobe CMaps 所在的 URL。可解决字体加载错误 | ||
| 398 | + }, | ||
| 399 | + textLayer: true, //文本是否可复制 , 文本复制和点击查看大图冲突建议把 pdfImageView 改为false | ||
| 400 | + pageOption: { | ||
| 401 | + current: 1, //当前页码 | ||
| 402 | + }, | ||
| 403 | + renderTotalPage: 5, //是否渲染指定页面总数,-1 则默认渲染文件总数,如果传5 则渲染前五页 | ||
| 404 | + // 不传默认是 0.5 | ||
| 405 | + visibleWindowPageRatio: 0.5, //当前pdf页面在可视窗口多少比例触发分页 传入0.5 就是 (pdf下一页滚动到容器高度一半的时候 更新当前页码) | ||
| 406 | + containerWidthScale: 0.97, //pdf 文件占父元素容器width的比例 默认是0.8 | ||
| 407 | + pdfItemBackgroundColor: "#fff", //pdf 加载时背景颜色 默认#ebebeb | ||
| 408 | + watermarkOptions: { | ||
| 409 | + //水印功能 | ||
| 410 | + columns: 3, //列数量 | ||
| 411 | + rows: 4, // 行数量 | ||
| 412 | + color: "#2f7a54", //字体颜色 | ||
| 413 | + rotation: 25, //旋转角度 | ||
| 414 | + fontSize: 40, //字体大小 | ||
| 415 | + opacity: 0.4, //调整透明度 | ||
| 416 | + // watermarkTextList: ["第一行", "第二行", "第三行"], //水印文字和 watermarkLink 冲突,只能展示一个水印内容 | ||
| 417 | + // watermarkLink: "https://xxx.png", //水印可以支持公司logo(图片路径) | ||
| 418 | + }, // 不展示水印传 undefined即可 | ||
| 419 | + }, | ||
| 420 | + }) | ||
| 421 | + }); | ||
| 422 | + }, 1000); | ||
| 423 | +} | ||
| 424 | + | ||
| 380 | onMounted(async () => { | 425 | onMounted(async () => { |
| 381 | // 延迟设置topWrapper和bottomWrapper的高度 | 426 | // 延迟设置topWrapper和bottomWrapper的高度 |
| 382 | setTimeout(() => { | 427 | setTimeout(() => { |
| ... | @@ -424,6 +469,8 @@ onMounted(async () => { | ... | @@ -424,6 +469,8 @@ onMounted(async () => { |
| 424 | } | 469 | } |
| 425 | 470 | ||
| 426 | } | 471 | } |
| 472 | + | ||
| 473 | + | ||
| 427 | }) | 474 | }) |
| 428 | 475 | ||
| 429 | // 提交评论 | 476 | // 提交评论 |
| ... | @@ -599,4 +646,12 @@ watch(showCommentPopup, async (newVal) => { | ... | @@ -599,4 +646,12 @@ watch(showCommentPopup, async (newVal) => { |
| 599 | :deep(.van-cell.van-field) { | 646 | :deep(.van-cell.van-field) { |
| 600 | background-color: rgb(244, 244, 244); | 647 | background-color: rgb(244, 244, 244); |
| 601 | } | 648 | } |
| 649 | + | ||
| 650 | +#pdf-container { | ||
| 651 | + margin-top: 3rem; | ||
| 652 | + // 高度100%-3rem | ||
| 653 | + height: calc(100% - 3rem); | ||
| 654 | + width: 100%; | ||
| 655 | + padding: 0px; | ||
| 656 | +} | ||
| 602 | </style> | 657 | </style> | ... | ... |
yarn.lock
0 → 100644
This diff could not be displayed because it is too large.
-
Please register or login to post a comment