vue3和uniapp(vant4)倒计时和触底组件
vue3:
<viewDocument ref="viewDocumentRef" :if-upload="'viewDocument'" :finish-click="handlefinishClick"/> import viewDocument from '@/components/ViewDocument/viewDocument.vue' const viewDocumentRef = ref() viewDocumentRef.value.open('1767591852797.pdf', 5) //完成 let handlefinishClick = (status)=>{ console.log(status,'完成') // emit('zdyFunction') // if(status == 'close'){} }
<template> <Dialog title="" v-model="dialogVisible" width="1000" lock-scroll style="height: 90vh;max-height: 100vh;margin-top: 4vh;" :fullscreen="false" @close="cancleFn" append-to-body> <div id="scrollable-element" style="height: calc(90vh - 166px); overflow: auto;"> <iframe :src="urlEle" frameborder="0" style="height: calc(90vh - 166px);" width="100%" v-if="showEle == 'txt'"> This browser does not support PDFs. Please download the PDF to view it </iframe> <officeWj ref="officeWjRef" :type="type" :src="urlEle" :height="'calc(90vh - 166px)'" :noUbload="ifUpload" :courseProgressData="courseProgressData" @show-yxw-menu="showYxwFlog=true" @document-view="chudiFn" v-if="showEle == 'office'" /> <video :id="playerId" ref="videoPlayer" v-show="showEle == 'video'" class="video-js vjs-big-play-centered" @ended="handleEnded" style="width: 100%;height: 100%;" preload="auto" > <source type="video/mp4" :src="urlEleVideo"/> </video> <!-- <audio ref="audioRef" :src="urlEle" v-show="showEle == 'audio'" preload="auto" controls></audio> --> <img :src="urlEle" alt="" style="width: 100%; height: auto; display: block;" v-if="showEle == 'img'" /> </div> <template #footer> <slot></slot> <div class="flex justify-center"> <span v-show="!flag">(请阅读完文档,并且 {{curTime}} 秒后可点击完成)</span><el-button @click="nextFn" :disabled="!flag"> 完 成 </el-button> <el-button @click="cancleFn"> 关 闭 </el-button> </div> </template> </Dialog> </template> <script setup lang="ts"> import { courseOfflineApi } from '@/api/education/course' import officeWj from '../../views/education/courseOffline/components/officeWj.vue' import { config } from '@/config/axios/config' import Videojs from 'video.js' import 'video.js/dist/video-js.css' import { nanoid } from 'nanoid'; defineOptions({name: 'Preview'}) const props = defineProps({ ifUpload: { type: String }, showDown: { type: Boolean, default: true }, finishClick: {type:Function,required:false}, }) const dialogVisible = ref(false) const type = ref() const showEle = ref('') const urlEle = ref('') const urlEleVideo = ref('') const audioName = ref('') const imgType = ['bmp', 'jpg', 'jpeg', 'png', 'tif', 'gif', 'pcx', 'tga', 'exif', 'fpx', 'svg', 'psd', 'cdr', 'pcd', 'dxf', 'ufo', 'eps', 'ai', 'raw', 'WMF', 'webp', 'avif', 'apng'] const message = useMessage() const studyState = ref('0') const courseProgressData = ref() const audioRef = ref() const videoPlayer = ref() const showYxwFlog = ref(false) const isOffice = ref(true) const playerId = ref() // const flag = ref(true) let flag = computed(() => { return flag1.value && flag2.value }) const flag1 = ref(false) // 倒计时 const flag2 = ref(false) // 触底 const curTime = ref('') let times = null const timer = (time)=>{ times = setInterval(()=>{ curTime.value = time-- if(time < 0){ clearInterval(times) // flag.value = false flag1.value = true } },1000) } provide('goToExam', '1') const open = async (filePath: string, time?: any) => { dialogVisible.value = true playerId.value = `mainVideo${nanoid(8)}` audioName.value = '' studyState.value = '2' // courseProgressData.value = '' curTime.value = time timer(time) type.value = getFileType(filePath) if(!type.value){ return } type.value = type.value.toLowerCase() urlEle.value = filePath await nextTick() const element = document.getElementById('scrollable-element')!; if( type.value && imgType.includes(type.value)){ showEle.value = 'img' if(props.ifUpload == 'no'){ return } element.addEventListener('scroll', function() { if (element.scrollHeight - element.scrollTop <= element.clientHeight + 50) { debounceScroll() } }); if(element.clientHeight >= element.scrollHeight){ showYxwFlog.value = true } }else if( type.value === "txt"){ showEle.value = 'txt' if(props.ifUpload == 'no'){ return } element.addEventListener('scroll', function() { if (element.scrollHeight - element.scrollTop <= element.clientHeight + 50) { debounceScroll() } }); if(element.clientHeight >= element.scrollHeight){ showYxwFlog.value = true } }else if ( type.value === "doc" || type.value === "docx" || type.value === "ppt" || type.value === "pptx" || type.value === 'xls' || type.value === 'xlsx' || type.value === "pdf" ){ element.addEventListener('scroll', function() { console.log('gd123') }); showEle.value = 'office' if(type.value === "pdf"){ isOffice.value = false }else{ isOffice.value = true } }else if (type.value === "mp4") { showEle.value = 'video' getVideoInfo(fileId) }else if (type.value === 'mp3'){ showEle.value = 'audio' if(props.ifUpload == 'no'){ return } nextTick(() => { audioRef.value.addEventListener('ended', function() { courseProgressFn() console.log('gd3') }); }) }else{ message.error("当前文件暂不支持预览") } } const getVideoInfo = async (fileId: string) => { const video = await courseOfflineApi.videoInfo(fileId) if(video != null) { urlEleVideo.value = config.base_url + video.streamUrl; setTimeout(() => { initPlayer(video.videoId) },500) } } const nextFn = ()=>{ // console.log('完成') // emit('zdyFunction') clearInterval(times) times = null handleClick() } //防御性检查 const handleClick = () => { // 检查 onItemClick 是否存在且是函数 if (typeof props.finishClick === 'function') { props.finishClick('finish') cancleFn() } } const chudiFn = ()=>{ // console.log('触底') flag2.value = true } const player = ref() const lastPlayedTime = ref(0) // 防止快进逻辑 const initPlayer = async (fileId: string) => { try { console.log(document.getElementById(playerId.value)) player.value = Videojs(playerId.value, { autoplay: true, controls: true, preload: 'metadata', // 仅预加载元数据,不提前加载完整流 html5: { vhs: { withCredentials: false, overrideNative: true, // 分片请求超时(调大,适配大视频) timeout: 60000, // 重试次数(网络波动时重试) retry: 3, // 分片大小(匹配后端10MB分片) chunkSize: 10 * 1024 * 1024 }, // 缓冲配置 buffer: { minBufferLength: 1, // 最小缓冲长度(秒) maxBufferLength: 30, // 最大缓冲长度(秒) bufferBehind: 5 // 保留后方缓冲(秒) } } }); urlEleVideo.value = `${config.base_url}big-file/big-video/stream/${fileId}?t=${Date.now()}` player.value.src({ src: urlEleVideo.value, type: 'video/mp4' }); player.value.ready(function() { console.log('播放器初始化完成'); player.value.on('loadedmetadata', () => { console.log('元数据加载完成,duration:', player.value.duration()); }); player.value.on('timeupdate', () => { lastPlayedTime.value = player.value.currentTime() }); player.value.on('seeked', () => { const current = player.value.currentTime(); if (Math.abs(current - lastPlayedTime.value) > 0.5) { // 判定为手动跳转 player.value.currentTime(lastPlayedTime.value); } }); const originalCurrentTime = player.value.currentTime; player.value.currentTime = function(time: number) { if (time === undefined) { return originalCurrentTime.call(this); // 允许读取当前时间 } message.error("禁止手动调整播放进度") return originalCurrentTime.call(this); // 禁止设置时间 }; }); } catch (error) { console.error('初始化播放器失败:', error); const errorMsg = error instanceof Error ? error.message : '未知错误' message.error(`初始化失败: ${errorMsg}`); } } const handleEnded = () => { courseProgressFn() } const emit = defineEmits<{ 'course-progress': [data: { endTime: number paperNo: string examState: string examStudentState: string }], 'zdyFunction' }>() //执行学习状态函数 const courseProgressFn = async () => { const data = await courseOfflineApi.courseProgress(courseProgressData.value) emit('course-progress', data) } //防抖动 const debounce = (func: () => void, delay: number) => { let timerId: NodeJS.Timeout; return function() { clearTimeout(timerId); timerId = setTimeout(() => { func(); }, delay); }; } const debounceScroll = debounce(courseProgressFn, 2000) const getFileType = (url: string) => { if (url) { if (url.lastIndexOf(".") > -1) { return url.slice(url.lastIndexOf(".") + 1); } else { return false } } } defineExpose({open}) const download = () => { // window.open( urlEle.value, '_blank') downloadPDFWithFetch(urlEle.value,audioName.value) } const downloadPDFWithFetch = async (url, filename = 'document.pdf') => { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = filename; document.body.appendChild(link); link.click(); // 清理 document.body.removeChild(link); window.URL.revokeObjectURL(blobUrl); } catch (error) { console.error('下载失败:', error); } } const officeWjRef = ref() const yxwMenuFn = () => { officeWjRef.value.courseProgressFn() } const cancleFn = async () => { dialogVisible.value = false showEle.value = '' urlEle.value = '' audioName.value = '' showYxwFlog.value = false flag1.value = false flag2.value = false clearInterval(times) times = null if(showEle.value = 'video'){ await nextTick() videoPlayer.value.pause() } if(showEle.value = 'audio'){ await nextTick() } // player.value.dispose() player.value = null } </script> <style lang="scss" scoped> video.video-ele::-webkit-media-controls-timeline { display: none; } </style>
uniapp(vant4):
<van-button type="primary" size="mini" @click="viewDocumentFileFn()">查看</van-button> <viewDocument :visible="viewDocumentVisible" :file-url="viewDocumentFileUrl" :file-time="viewDocumentFileTime" :finish-click="handlefinishClick" @update:visible="(val) => viewDocumentVisible = val"/> import viewDocument from '../../../components/FilePreview/viewDocument.vue' // 预览组件相关状态 const viewDocumentVisible = ref(false) const viewDocumentFileUrl = ref('') const viewDocumentFileTime = ref(0) const viewDocumentFileFn = () => { // 设置预览文件信息 viewDocumentFileUrl.value = '1767594415285.pdf' viewDocumentFileTime.value = 10 // 显示预览组件 viewDocumentVisible.value = true } //完成 let handlefinishClick = (status)=>{ console.log(status,'完成') // if(status == 'close'){} }
<template> <view class="popup-file-preview" v-if="visible"> <!-- 遮罩层 --> <view class="mask" @click="closePopup"></view> <!-- 弹出层内容 --> <view class="popup-content"> <!-- 头部标题栏 --> <view class="popup-header"> <text class="file-title">{{ fileName }}</text> <view class="header-actions"> <view class="action-btn" @click.stop="handleDownload"> <van-icon name="download" size="18" color="#fff" /> </view> <view class="close-btn" @click.stop="closePopup"> <van-icon name="cross" size="20" color="#fff" /> </view> </view> </view> <!-- 文件预览区域 --> <view class="preview-container"> <!-- 加载状态 --> <view v-if="loading" class="loading-container"> <van-loading type="spinner" color="#1989fa" /> <text class="loading-text">加载中...</text> </view> <!-- 图片文件预览 --> <view v-else-if="isImageFile" class="image-preview"> <image :src="fileUrl" mode="widthFix" @click="handleImageClick" @error="onImageError" show-menu-by-longpress /> </view> <!-- PDF文件预览 --> <view v-else-if="fileType === 'pdf'" class="pdf-preview"> <view class="pdf-controls" v-if="pdfPages.length > 0" v-show="false"> <van-button size="small" @click="prevPdfPage" :disabled="currentPdfPage <= 1" type="primary" plain > 上一页 </van-button> <text class="page-info">{{ currentPdfPage }} / {{ pdfPages.length }}</text> <van-button size="small" @click="nextPdfPage" :disabled="currentPdfPage >= pdfPages.length" type="primary" plain > 下一页 </van-button> </view> <!-- <view class="pdf-pages-container"> --> <scroll-view class="pdf-pages-container" ref="officeElement" @scroll="handleScroll" scroll-y > <image v-for="(page, index) in pdfPages" :key="index" :src="page" mode="widthFix" class="pdf-page-image" /> </scroll-view> <!-- </view> --> <view class="pdf-fallback"> <text v-show="!flag">(请阅读完文档,并且 {{curTime}} 秒后可点击完成)</text> <van-button type="primary" size="small" @click="nextFn" :disabled="!flag" style="margin-top: 16px;"> 完成 </van-button> </view> <view class="pdf-fallback" v-if="pdfPages.length === 0 && !loading"> <van-empty description="PDF预览不可用" /> <van-button type="primary" @click="handleDownload" style="margin-top: 16px;"> 下载文件查看 </van-button> </view> </view> <!-- TXT文件预览 --> <view v-else-if="fileType === 'txt'" class="txt-preview"> <scroll-view class="txt-scroll" scroll-y> <text class="txt-content">{{ txtContent }}</text> </scroll-view> </view> <!-- 文档文件预览 (DOC/DOCX) --> <view v-else-if="isDocFile" class="doc-preview"> <view v-if="docContent" class="doc-content"> <rich-text :nodes="docContent"></rich-text> </view> <view v-else class="doc-fallback"> <van-empty description="文档预览不可用" /> <van-button type="primary" @click="handleDownload" style="margin-top: 16px;"> 下载文件查看 </van-button> </view> </view> <!-- 不支持的文件类型 --> <view v-else class="unsupported-preview"> <van-empty description="暂不支持该类型文件预览" /> <van-button type="primary" @click="handleDownload" style="margin-top: 16px;"> 下载文件 </van-button> </view> </view> <!-- 文件信息 --> <view class="file-info" v-if="fileSize"> <text>{{ fileSize }}</text> </view> </view> </view> </template> <script setup> import { ref, watch, onMounted, computed } from 'vue'; import { showImagePreview, showToast } from 'vant'; // 定义组件属性 const props = defineProps({ visible: { type: Boolean, default: false }, fileUrl: { type: String, required: true }, fileType: { type: String, default: 'pdf' }, fileName: { type: String, default: '' }, fileSize: { type: String, default: '' }, fileTime: { type: Number, default: 5 }, finishClick: {type:Function,required:false}, }); let flag = computed(() => { return flag1.value && flag2.value }) const flag1 = ref(false) // 倒计时 const flag2 = ref(false) // 触底 const curTime = ref('') let times = null const timer = (time)=>{ times = setInterval(()=>{ curTime.value = time-- if(time < 0){ clearInterval(times) flag1.value = true } },1000) } const nextFn = ()=>{ // console.log('完成') // emit('zdyFunction') clearInterval(times) times = null handleClick() } //防御性检查 const handleClick = () => { // 检查 onItemClick 是否存在且是函数 if (typeof props.finishClick === 'function') { props.finishClick('finish') closePopup() } } const officeElement = ref(); const handleScroll = (e) => { const scrollTop = e.detail.scrollTop; // 获取元素高度等信息 uni.createSelectorQuery() .select('.pdf-pages-container') .boundingClientRect((rect) => { if (rect) { const { height: clientHeight } = rect; // 计算是否接近底部 uni.createSelectorQuery() .select('.pdf-page-image:last-child') .boundingClientRect((lastRect) => { if (lastRect) { const scrollHeight = scrollTop + lastRect.bottom; if ((scrollHeight - scrollTop) < (clientHeight + 150)) { // console.log('触底') flag2.value = true } } }) .exec(); } }) .exec(); } // 定义事件 const emit = defineEmits(['update:visible']); // 响应式数据 const loading = ref(false); const txtContent = ref(''); const docContent = ref(''); const pdfPages = ref([]); const currentPdfPage = ref(1); // 计算属性 const isDocFile = computed(() => { return ['doc', 'docx'].includes(props.fileType?.toLowerCase()); }); const isImageFile = computed(() => { return ['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(props.fileType?.toLowerCase()); }); // 方法定义 const closePopup = () => { emit('update:visible', false); flag1.value = false flag2.value = false clearInterval(times) times = null }; const handleDownload = () => { // #ifdef H5 try { const link = document.createElement('a'); link.href = props.fileUrl; link.download = props.fileName; link.target = '_blank'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } catch (error) { console.error('下载文件失败:', error); showToast('下载失败'); } // #endif // #ifndef H5 uni.downloadFile({ url: props.fileUrl, success: (res) => { if (res.statusCode === 200) { uni.saveFile({ tempFilePath: res.tempFilePath, success: (saveRes) => { uni.openDocument({ filePath: saveRes.savedFilePath, showMenu: true, success: () => { console.log('打开文档成功'); }, fail: (err) => { console.error('打开文档失败:', err); showToast('打开文档失败'); } }); }, fail: (err) => { console.error('保存文件失败:', err); showToast('保存文件失败'); } }); } }, fail: (err) => { console.error('下载文件失败:', err); showToast('下载文件失败'); } }); // #endif }; const handleImageClick = () => { showImagePreview([props.fileUrl]); }; const onImageError = (e) => { console.error('图片加载失败:', e); showToast('图片加载失败'); }; // TXT文件加载 const loadTxtFile = async () => { try { const res = await uni.request({ url: props.fileUrl, method: 'GET', responseType: 'text' }); if (res.statusCode === 200) { txtContent.value = res.data; } else { throw new Error(`HTTP ${res.statusCode}`); } } catch (error) { console.error('加载TXT文件失败:', error); showToast('文本加载失败'); } }; // DOC/DOCX文件加载 const loadDocFile = async () => { try { // #ifdef H5 // H5环境下使用mammoth处理 const response = await fetch(props.fileUrl); if (!response.ok) throw new Error('网络请求失败'); const arrayBuffer = await response.arrayBuffer(); const mammoth = await import('mammoth'); const result = await mammoth.convertToHtml({ arrayBuffer }); docContent.value = result.value; // #endif // #ifndef H5 // 非H5环境下提示下载 showToast('文档需下载查看'); // #endif } catch (error) { console.error('加载文档失败:', error); showToast('文档加载失败'); } }; // PDF文件加载(使用图片方式预览) const loadPdfFile = async () => { try { // 重置状态 pdfPages.value = []; currentPdfPage.value = 1; // #ifdef H5 // H5环境下尝试使用PDF.js处理 const pdfjsLib = await import('pdfjs-dist'); const pdfjsWorker = await import('pdfjs-dist/build/pdf.worker.min?url'); pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker.default; const loadingTask = pdfjsLib.getDocument(props.fileUrl); const pdf = await loadingTask.promise; const pages = []; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.height = viewport.height; canvas.width = viewport.width; const renderContext = { canvasContext: context, viewport: viewport }; await page.render(renderContext).promise; const dataUrl = canvas.toDataURL(); pages.push(dataUrl); } pdfPages.value = pages; // #endif // #ifndef H5 // 非H5环境下提示下载 showToast('PDF需下载查看'); // #endif } catch (error) { console.error('加载PDF失败:', error); showToast('PDF加载失败'); } }; // PDF翻页控制 const prevPdfPage = () => { if (currentPdfPage.value > 1) { currentPdfPage.value--; } }; const nextPdfPage = () => { if (currentPdfPage.value < pdfPages.value.length) { currentPdfPage.value++; } }; // 根据文件类型加载文件 const loadFileByType = async () => { loading.value = true; try { const type = props.fileType?.toLowerCase(); if (isImageFile.value) { // 图片文件不需要特殊处理,直接显示 loading.value = false; } else if (type === 'txt') { await loadTxtFile(); loading.value = false; } else if (type === 'pdf') { await loadPdfFile(); loading.value = false; } else if (isDocFile.value) { await loadDocFile(); loading.value = false; } else { loading.value = false; } } catch (error) { console.error('文件加载失败:', error); loading.value = false; showToast('文件加载失败'); } }; // 监听文件URL变化 watch(() => props.fileUrl, (newUrl) => { if (props.visible && newUrl) { loadFileByType(); } }); // 监听弹窗显示状态变化 watch(() => props.visible, (newVal) => { if (newVal && props.fileUrl) { loadFileByType(); curTime.value = props.fileTime timer(props.fileTime) } else { // 重置状态 txtContent.value = ''; docContent.value = ''; pdfPages.value = []; currentPdfPage.value = 1; loading.value = false; flag1.value = false flag2.value = false clearInterval(times) times = null } }, { immediate: true }); // 组件挂载时加载文件 onMounted(() => { if (props.visible && props.fileUrl) { loadFileByType(); } }); </script> <style scoped> .popup-file-preview { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; } .mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 200; background-color: rgba(0, 0, 0, 0.6); } .popup-content { position: absolute; top: 50%; left: 50%; z-index: 300; transform: translate(-50%, -50%); width: 90vw; height: 90vh; background-color: #fff; border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); } .popup-header { height: 50px; background-color: #1989fa; display: flex; justify-content: space-between; align-items: center; padding: 0 15px; z-index: 350; } .file-title { color: #fff; font-size: 16px; font-weight: bold; flex: 1; text-align: center; margin-right: 30px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; } .header-actions { display: flex; align-items: center; gap: 10px; } .action-btn, .close-btn { width: 30px; height: 30px; display: flex; justify-content: center; align-items: center; cursor: pointer; border-radius: 4px; transition: background-color 0.2s; } .action-btn:hover, .close-btn:hover { background-color: rgba(255, 255, 255, 0.1); } .preview-container { flex: 1; background-color: #f5f5f5; overflow: hidden; position: relative; } .loading-container { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; } .loading-text { font-size: 14px; color: #969799; } /* 图片预览 */ .image-preview { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; background-color: #000; } .image-preview image { max-width: 100%; max-height: 100%; object-fit: contain; } /* PDF预览 */ .pdf-preview { width: 100%; height: 100%; display: flex; flex-direction: column; } .pdf-controls { padding: 10px; background-color: #fff; display: flex; justify-content: center; align-items: center; gap: 15px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); z-index: 10; } .page-info { font-size: 14px; color: #333; min-width: 80px; text-align: center; } .pdf-pages-container { overflow-y: auto; /* flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; */ background-color: #f0f0f0; } .pdf-page-image { width: 100%; display: block; } .pdf-fallback { flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 20px; background-color: #fff; } /* TXT预览 */ .txt-preview { width: 100%; height: 100%; background-color: #fff; } .txt-scroll { width: 100%; height: 100%; padding: 16px; } .txt-content { font-size: 14px; line-height: 1.8; color: #333; white-space: pre-wrap; word-wrap: break-word; } /* 文档预览 */ .doc-preview { width: 100%; height: 100%; background-color: #fff; overflow: auto; } .doc-content { padding: 16px; min-height: 100%; } .doc-fallback { height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 20px; } /* 不支持的文件类型 */ .unsupported-preview { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 20px; background-color: #fff; } /* 文件信息 */ .file-info { height: 40px; padding: 0 15px; display: flex; align-items: center; background-color: #f8f8f8; border-top: 1px solid #eee; color: #666; font-size: 14px; } </style>

浙公网安备 33010602011771号