hookehuyr

feat(文件预览): 添加音频和视频文件预览功能

实现文件点击预览功能,支持音频和视频文件的播放
添加音频播放器和视频播放器组件
处理多种文件URL获取方式
优化文件列表项的交互样式
...@@ -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 {
......