feat(文件预览): 添加音频和视频文件预览功能
实现文件点击预览功能,支持音频和视频文件的播放 添加音频播放器和视频播放器组件 处理多种文件URL获取方式 优化文件列表项的交互样式
Showing
1 changed file
with
326 additions
and
2 deletions
| ... | @@ -63,6 +63,7 @@ | ... | @@ -63,6 +63,7 @@ |
| 63 | :before-read="beforeRead" | 63 | :before-read="beforeRead" |
| 64 | :after-read="afterRead" | 64 | :after-read="afterRead" |
| 65 | @delete="onDelete" | 65 | @delete="onDelete" |
| 66 | + @click-preview="onClickPreview" | ||
| 66 | multiple | 67 | multiple |
| 67 | :accept="getAcceptType()" | 68 | :accept="getAcceptType()" |
| 68 | result-type="file" | 69 | result-type="file" |
| ... | @@ -72,7 +73,7 @@ | ... | @@ -72,7 +73,7 @@ |
| 72 | <!-- 文件列表显示 --> | 73 | <!-- 文件列表显示 --> |
| 73 | <div v-if="fileList.length > 0" class="file-list"> | 74 | <div v-if="fileList.length > 0" class="file-list"> |
| 74 | <div v-for="(item, index) in fileList" :key="index" class="file-item"> | 75 | <div v-for="(item, index) in fileList" :key="index" class="file-item"> |
| 75 | - <div class="file-info"> | 76 | + <div class="file-info" @click="previewFile(item)"> |
| 76 | <van-icon :name="getFileIcon()" size="1rem" /> | 77 | <van-icon :name="getFileIcon()" size="1rem" /> |
| 77 | <span class="file-name">{{ item.name || item.file?.name }}</span> | 78 | <span class="file-name">{{ item.name || item.file?.name }}</span> |
| 78 | <span class="file-status" :class="item.status">{{ item.message }}</span> | 79 | <span class="file-status" :class="item.status">{{ item.message }}</span> |
| ... | @@ -116,16 +117,79 @@ | ... | @@ -116,16 +117,79 @@ |
| 116 | <van-loading vertical color="#FFFFFF">上传中...</van-loading> | 117 | <van-loading vertical color="#FFFFFF">上传中...</van-loading> |
| 117 | </div> | 118 | </div> |
| 118 | </van-overlay> | 119 | </van-overlay> |
| 120 | + | ||
| 121 | + <!-- 音频播放器弹窗 --> | ||
| 122 | + <van-popup | ||
| 123 | + v-model:show="audioShow" | ||
| 124 | + position="bottom" | ||
| 125 | + round | ||
| 126 | + closeable | ||
| 127 | + :style="{ height: '60%', width: '100%' }" | ||
| 128 | + > | ||
| 129 | + <div class="p-4"> | ||
| 130 | + <h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3> | ||
| 131 | + <AudioPlayer | ||
| 132 | + v-if="audioShow && audioUrl" | ||
| 133 | + :songs="[{ title: audioTitle, url: audioUrl }]" | ||
| 134 | + class="w-full" | ||
| 135 | + /> | ||
| 136 | + </div> | ||
| 137 | + </van-popup> | ||
| 138 | + | ||
| 139 | + <!-- 视频播放器弹窗 --> | ||
| 140 | + <van-popup | ||
| 141 | + v-model:show="videoShow" | ||
| 142 | + position="center" | ||
| 143 | + round | ||
| 144 | + closeable | ||
| 145 | + :style="{ width: '95%', maxHeight: '80vh' }" | ||
| 146 | + @close="stopVideoPlay" | ||
| 147 | + > | ||
| 148 | + <div class="p-4"> | ||
| 149 | + <h3 class="text-lg font-medium mb-4 text-center">视频预览</h3> | ||
| 150 | + <div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;"> | ||
| 151 | + <!-- 视频封面 --> | ||
| 152 | + <div | ||
| 153 | + v-show="!isVideoPlaying" | ||
| 154 | + class="absolute inset-0 flex items-center justify-center cursor-pointer" | ||
| 155 | + @click="startVideoPlay" | ||
| 156 | + > | ||
| 157 | + <img :src="videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png'" | ||
| 158 | + :alt="videoTitle" class="w-full h-full object-cover" /> | ||
| 159 | + <div class="absolute inset-0 flex items-center justify-center bg-black/20"> | ||
| 160 | + <div | ||
| 161 | + class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors"> | ||
| 162 | + <van-icon name="play-circle-o" class="text-white" size="40" /> | ||
| 163 | + </div> | ||
| 164 | + </div> | ||
| 165 | + </div> | ||
| 166 | + <!-- 视频播放器 --> | ||
| 167 | + <VideoPlayer | ||
| 168 | + v-show="isVideoPlaying" | ||
| 169 | + ref="videoPlayerRef" | ||
| 170 | + :video-url="videoUrl" | ||
| 171 | + :video-id="videoTitle" | ||
| 172 | + :autoplay="false" | ||
| 173 | + class="w-full h-full" | ||
| 174 | + @play="handleVideoPlay" | ||
| 175 | + @pause="handleVideoPause" | ||
| 176 | + /> | ||
| 177 | + </div> | ||
| 178 | + </div> | ||
| 179 | + </van-popup> | ||
| 119 | </div> | 180 | </div> |
| 120 | </template> | 181 | </template> |
| 121 | 182 | ||
| 122 | <script setup> | 183 | <script setup> |
| 123 | -import { ref, computed, onMounted } from 'vue' | 184 | +import { ref, computed, onMounted, nextTick } from 'vue' |
| 124 | import { useRoute, useRouter } from 'vue-router' | 185 | import { useRoute, useRouter } from 'vue-router' |
| 125 | import { getTaskDetailAPI } from "@/api/checkin" | 186 | import { getTaskDetailAPI } from "@/api/checkin" |
| 126 | import { getTeacherFindSettingsAPI } from '@/api/teacher' | 187 | import { getTeacherFindSettingsAPI } from '@/api/teacher' |
| 127 | import { useTitle } from '@vueuse/core' | 188 | import { useTitle } from '@vueuse/core' |
| 128 | import { useCheckin } from '@/composables/useCheckin' | 189 | import { useCheckin } from '@/composables/useCheckin' |
| 190 | +import AudioPlayer from '@/components/ui/AudioPlayer.vue' | ||
| 191 | +import VideoPlayer from '@/components/ui/VideoPlayer.vue' | ||
| 192 | +import { showToast } from 'vant' | ||
| 129 | import dayjs from 'dayjs' | 193 | import dayjs from 'dayjs' |
| 130 | 194 | ||
| 131 | const route = useRoute() | 195 | const route = useRoute() |
| ... | @@ -159,6 +223,17 @@ const attachmentTypeOptions = ref([]) | ... | @@ -159,6 +223,17 @@ const attachmentTypeOptions = ref([]) |
| 159 | // 是否为编辑模式 | 223 | // 是否为编辑模式 |
| 160 | const isEditMode = computed(() => route.query.status === 'edit') | 224 | const isEditMode = computed(() => route.query.status === 'edit') |
| 161 | 225 | ||
| 226 | +// 预览相关变量 | ||
| 227 | +const audioShow = ref(false) | ||
| 228 | +const audioTitle = ref('') | ||
| 229 | +const audioUrl = ref('') | ||
| 230 | +const videoShow = ref(false) | ||
| 231 | +const videoTitle = ref('') | ||
| 232 | +const videoUrl = ref('') | ||
| 233 | +const videoCover = ref('') | ||
| 234 | +const isVideoPlaying = ref(false) | ||
| 235 | +const videoPlayerRef = ref(null) | ||
| 236 | + | ||
| 162 | /** | 237 | /** |
| 163 | * 返回上一页 | 238 | * 返回上一页 |
| 164 | */ | 239 | */ |
| ... | @@ -305,6 +380,247 @@ const getTaskDetail = async (month) => { | ... | @@ -305,6 +380,247 @@ const getTaskDetail = async (month) => { |
| 305 | } | 380 | } |
| 306 | 381 | ||
| 307 | /** | 382 | /** |
| 383 | + * van-uploader点击预览事件处理 | ||
| 384 | + * @param {Object} file - 文件对象 | ||
| 385 | + * @param {Object} detail - 详细信息 | ||
| 386 | + */ | ||
| 387 | +const onClickPreview = (file, detail) => { | ||
| 388 | + console.log('onClickPreview - file:', file) | ||
| 389 | + console.log('onClickPreview - detail:', detail) | ||
| 390 | + console.log('file对象的所有属性:', Object.keys(file)) | ||
| 391 | + | ||
| 392 | + const fileName = file.name || file.file?.name || '' | ||
| 393 | + | ||
| 394 | + // 尝试多种方式获取文件URL | ||
| 395 | + let fileUrl = '' | ||
| 396 | + | ||
| 397 | + // 方式1: 直接从file对象获取 | ||
| 398 | + if (file.url) { | ||
| 399 | + fileUrl = file.url | ||
| 400 | + console.log('从file.url获取URL:', fileUrl) | ||
| 401 | + } | ||
| 402 | + // 方式2: 从file.content获取 | ||
| 403 | + else if (file.content) { | ||
| 404 | + fileUrl = file.content | ||
| 405 | + console.log('从file.content获取URL:', fileUrl) | ||
| 406 | + } | ||
| 407 | + // 方式3: 从file.objectURL获取 | ||
| 408 | + else if (file.objectURL) { | ||
| 409 | + fileUrl = file.objectURL | ||
| 410 | + console.log('从file.objectURL获取URL:', fileUrl) | ||
| 411 | + } | ||
| 412 | + // 方式4: 从file.file获取 | ||
| 413 | + else if (file.file) { | ||
| 414 | + if (file.file.url) { | ||
| 415 | + fileUrl = file.file.url | ||
| 416 | + console.log('从file.file.url获取URL:', fileUrl) | ||
| 417 | + } else { | ||
| 418 | + // 创建临时URL | ||
| 419 | + try { | ||
| 420 | + fileUrl = URL.createObjectURL(file.file) | ||
| 421 | + console.log('通过URL.createObjectURL创建URL:', fileUrl) | ||
| 422 | + } catch (error) { | ||
| 423 | + console.error('创建ObjectURL失败:', error) | ||
| 424 | + } | ||
| 425 | + } | ||
| 426 | + } | ||
| 427 | + // 方式5: 检查是否有其他可能的URL字段 | ||
| 428 | + else { | ||
| 429 | + const possibleUrlFields = ['src', 'path', 'value', 'href', 'link'] | ||
| 430 | + for (const field of possibleUrlFields) { | ||
| 431 | + if (file[field]) { | ||
| 432 | + fileUrl = file[field] | ||
| 433 | + console.log(`从file.${field}获取URL:`, fileUrl) | ||
| 434 | + break | ||
| 435 | + } | ||
| 436 | + } | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + console.log('最终提取的文件名:', fileName) | ||
| 440 | + console.log('最终提取的文件URL:', fileUrl) | ||
| 441 | + | ||
| 442 | + if (!fileUrl) { | ||
| 443 | + console.warn('文件URL不存在,文件对象完整结构:', JSON.stringify(file, null, 2)) | ||
| 444 | + showToast('无法获取文件URL,请检查文件是否上传成功') | ||
| 445 | + return | ||
| 446 | + } | ||
| 447 | + | ||
| 448 | + // 根据打卡类型或文件扩展名判断文件类型 | ||
| 449 | + if (activeType.value === 'audio' || isAudioFile(fileName)) { | ||
| 450 | + console.log('准备播放音频:', fileName, fileUrl) | ||
| 451 | + showAudio(fileName, fileUrl) | ||
| 452 | + } else if (activeType.value === 'video' || isVideoFile(fileName)) { | ||
| 453 | + console.log('准备播放视频:', fileName, fileUrl) | ||
| 454 | + showVideo(fileName, fileUrl) | ||
| 455 | + } else { | ||
| 456 | + console.log('该文件类型不支持预览,文件名:', fileName, '打卡类型:', activeType.value) | ||
| 457 | + showToast('该文件类型不支持预览') | ||
| 458 | + } | ||
| 459 | +} | ||
| 460 | + | ||
| 461 | +/** | ||
| 462 | + * 预览文件 | ||
| 463 | + * @param {Object} item - 文件项 | ||
| 464 | + */ | ||
| 465 | +const previewFile = (item) => { | ||
| 466 | + console.log('previewFile - item:', item) | ||
| 467 | + console.log('previewFile - item对象的所有属性:', Object.keys(item)) | ||
| 468 | + | ||
| 469 | + const fileName = item.name || item.file?.name || '' | ||
| 470 | + | ||
| 471 | + // 尝试多种方式获取文件URL | ||
| 472 | + let fileUrl = '' | ||
| 473 | + | ||
| 474 | + // 方式1: 直接从item对象获取 | ||
| 475 | + if (item.url) { | ||
| 476 | + fileUrl = item.url | ||
| 477 | + console.log('从item.url获取URL:', fileUrl) | ||
| 478 | + } | ||
| 479 | + // 方式2: 从item.value获取 | ||
| 480 | + else if (item.value) { | ||
| 481 | + fileUrl = item.value | ||
| 482 | + console.log('从item.value获取URL:', fileUrl) | ||
| 483 | + } | ||
| 484 | + // 方式3: 从item.content获取 | ||
| 485 | + else if (item.content) { | ||
| 486 | + fileUrl = item.content | ||
| 487 | + console.log('从item.content获取URL:', fileUrl) | ||
| 488 | + } | ||
| 489 | + // 方式4: 从item.objectURL获取 | ||
| 490 | + else if (item.objectURL) { | ||
| 491 | + fileUrl = item.objectURL | ||
| 492 | + console.log('从item.objectURL获取URL:', fileUrl) | ||
| 493 | + } | ||
| 494 | + // 方式5: 从item.file获取 | ||
| 495 | + else if (item.file) { | ||
| 496 | + if (item.file.url) { | ||
| 497 | + fileUrl = item.file.url | ||
| 498 | + console.log('从item.file.url获取URL:', fileUrl) | ||
| 499 | + } else { | ||
| 500 | + // 创建临时URL | ||
| 501 | + try { | ||
| 502 | + fileUrl = URL.createObjectURL(item.file) | ||
| 503 | + console.log('通过URL.createObjectURL创建URL:', fileUrl) | ||
| 504 | + } catch (error) { | ||
| 505 | + console.error('创建ObjectURL失败:', error) | ||
| 506 | + } | ||
| 507 | + } | ||
| 508 | + } | ||
| 509 | + // 方式6: 检查是否有其他可能的URL字段 | ||
| 510 | + else { | ||
| 511 | + const possibleUrlFields = ['src', 'path', 'href', 'link', 'downloadUrl', 'previewUrl'] | ||
| 512 | + for (const field of possibleUrlFields) { | ||
| 513 | + if (item[field]) { | ||
| 514 | + fileUrl = item[field] | ||
| 515 | + console.log(`从item.${field}获取URL:`, fileUrl) | ||
| 516 | + break | ||
| 517 | + } | ||
| 518 | + } | ||
| 519 | + } | ||
| 520 | + | ||
| 521 | + console.log('最终提取的文件名:', fileName) | ||
| 522 | + console.log('最终提取的文件URL:', fileUrl) | ||
| 523 | + | ||
| 524 | + if (!fileUrl) { | ||
| 525 | + console.warn('文件URL不存在,文件对象完整结构:', JSON.stringify(item, null, 2)) | ||
| 526 | + showToast('无法获取文件URL,请检查文件是否上传成功') | ||
| 527 | + return | ||
| 528 | + } | ||
| 529 | + | ||
| 530 | + // 根据打卡类型或文件扩展名判断文件类型 | ||
| 531 | + if (activeType.value === 'audio' || isAudioFile(fileName)) { | ||
| 532 | + console.log('准备播放音频:', fileName, fileUrl) | ||
| 533 | + showAudio(fileName, fileUrl) | ||
| 534 | + } else if (activeType.value === 'video' || isVideoFile(fileName)) { | ||
| 535 | + console.log('准备播放视频:', fileName, fileUrl) | ||
| 536 | + showVideo(fileName, fileUrl) | ||
| 537 | + } else { | ||
| 538 | + console.log('该文件类型不支持预览,文件名:', fileName, '打卡类型:', activeType.value) | ||
| 539 | + showToast('该文件类型不支持预览') | ||
| 540 | + } | ||
| 541 | +} | ||
| 542 | + | ||
| 543 | +/** | ||
| 544 | + * 判断是否为音频文件 | ||
| 545 | + * @param {string} fileName - 文件名 | ||
| 546 | + * @returns {boolean} | ||
| 547 | + */ | ||
| 548 | +const isAudioFile = (fileName) => { | ||
| 549 | + const audioExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac'] | ||
| 550 | + return audioExtensions.some(ext => fileName.toLowerCase().includes(ext)) | ||
| 551 | +} | ||
| 552 | + | ||
| 553 | +/** | ||
| 554 | + * 判断是否为视频文件 | ||
| 555 | + * @param {string} fileName - 文件名 | ||
| 556 | + * @returns {boolean} | ||
| 557 | + */ | ||
| 558 | +const isVideoFile = (fileName) => { | ||
| 559 | + const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv'] | ||
| 560 | + return videoExtensions.some(ext => fileName.toLowerCase().includes(ext)) | ||
| 561 | +} | ||
| 562 | + | ||
| 563 | +/** | ||
| 564 | + * 显示音频播放器 | ||
| 565 | + * @param {string} title - 音频标题 | ||
| 566 | + * @param {string} url - 音频URL | ||
| 567 | + */ | ||
| 568 | +const showAudio = (title, url) => { | ||
| 569 | + audioTitle.value = title | ||
| 570 | + audioUrl.value = url | ||
| 571 | + audioShow.value = true | ||
| 572 | +} | ||
| 573 | + | ||
| 574 | +/** | ||
| 575 | + * 显示视频播放器 | ||
| 576 | + * @param {string} title - 视频标题 | ||
| 577 | + * @param {string} url - 视频URL | ||
| 578 | + * @param {string} cover - 视频封面URL(可选) | ||
| 579 | + */ | ||
| 580 | +const showVideo = (title, url, cover = '') => { | ||
| 581 | + videoTitle.value = title | ||
| 582 | + videoUrl.value = url | ||
| 583 | + videoCover.value = cover | ||
| 584 | + videoShow.value = true | ||
| 585 | + isVideoPlaying.value = false // 重置播放状态 | ||
| 586 | +} | ||
| 587 | + | ||
| 588 | +/** | ||
| 589 | + * 开始播放视频 | ||
| 590 | + */ | ||
| 591 | +const startVideoPlay = async () => { | ||
| 592 | + isVideoPlaying.value = true | ||
| 593 | + await nextTick() | ||
| 594 | + if (videoPlayerRef.value) { | ||
| 595 | + videoPlayerRef.value.play() | ||
| 596 | + } | ||
| 597 | +} | ||
| 598 | + | ||
| 599 | +/** | ||
| 600 | + * 处理视频播放事件 | ||
| 601 | + */ | ||
| 602 | +const handleVideoPlay = () => { | ||
| 603 | + isVideoPlaying.value = true | ||
| 604 | +} | ||
| 605 | + | ||
| 606 | +/** | ||
| 607 | + * 处理视频暂停事件 | ||
| 608 | + */ | ||
| 609 | +const handleVideoPause = () => { | ||
| 610 | + // 保持视频播放器可见,只在初始状态显示封面 | ||
| 611 | +} | ||
| 612 | + | ||
| 613 | +/** | ||
| 614 | + * 停止视频播放 | ||
| 615 | + */ | ||
| 616 | +const stopVideoPlay = () => { | ||
| 617 | + if (videoPlayerRef.value && typeof videoPlayerRef.value.pause === 'function') { | ||
| 618 | + videoPlayerRef.value.pause() | ||
| 619 | + } | ||
| 620 | + isVideoPlaying.value = false | ||
| 621 | +} | ||
| 622 | + | ||
| 623 | +/** | ||
| 308 | * 页面挂载时的初始化逻辑 | 624 | * 页面挂载时的初始化逻辑 |
| 309 | */ | 625 | */ |
| 310 | onMounted(async () => { | 626 | onMounted(async () => { |
| ... | @@ -597,6 +913,14 @@ onMounted(async () => { | ... | @@ -597,6 +913,14 @@ onMounted(async () => { |
| 597 | align-items: center; | 913 | align-items: center; |
| 598 | flex: 1; | 914 | flex: 1; |
| 599 | gap: 0.5rem; | 915 | gap: 0.5rem; |
| 916 | + cursor: pointer; | ||
| 917 | + padding: 0.5rem; | ||
| 918 | + border-radius: 0.5rem; | ||
| 919 | + transition: background-color 0.2s; | ||
| 920 | + | ||
| 921 | + &:hover { | ||
| 922 | + background-color: #f5f5f5; | ||
| 923 | + } | ||
| 600 | } | 924 | } |
| 601 | 925 | ||
| 602 | .file-name { | 926 | .file-name { | ... | ... |
-
Please register or login to post a comment