hookehuyr

fix(video): 预加载Video.js资源并优化视频播放诊断

- 在HomePage和StudyDetailPage添加Video.js资源预加载函数,解决首次播放黑屏问题
- 为VideoPlayer组件添加加载状态占位符,改善用户体验
- 增强视频播放错误处理和调试日志,便于问题排查
- 修复play()方法调用时的Promise处理,避免静默失败
- 优化跨域视频加载配置和网络超时设置
......@@ -15,6 +15,7 @@
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
preload="metadata"
crossorigin="anonymous"
@play="handleNativePlay"
@pause="handleNativePause"
/>
......@@ -31,6 +32,12 @@
@pause="handlePause"
/>
<!-- Loading 占位符:Video.js 资源加载时显示 -->
<div v-if="!useNativePlayer && !state" class="loading-placeholder">
<div class="loading-spinner"></div>
<div class="loading-text">视频加载中...</div>
</div>
<!-- <div v-if="showNetworkSpeedOverlay && !showErrorOverlay" class="speed-overlay">
<div class="speed-content">
<div
......@@ -45,9 +52,8 @@
</div>
</div> -->
<div v-if="hlsDownloadSpeedText" class="hls-speed-badge">{{ hlsDownloadSpeedText }}</div>
<div v-if="props.debug" class="hls-debug-badge">
{{ hlsSpeedDebugText || "debug:empty" }}
<div v-if="hlsDownloadSpeedText && !props.debug" class="hls-speed-badge">
{{ hlsDownloadSpeedText }}
</div>
<!-- 错误提示覆盖层 -->
......@@ -62,17 +68,17 @@
</template>
<script setup>
import { defineAsyncComponent, ref } from "vue";
import { useVideoPlayer } from "@/composables/useVideoPlayer";
import { defineAsyncComponent, ref, computed, watch } from 'vue'
import { useVideoPlayer } from '@/composables/useVideoPlayer'
const VideoPlayer = defineAsyncComponent(async () => {
await import("video.js/dist/video-js.css");
await import("videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css");
await import("videojs-contrib-quality-levels");
await import("videojs-hls-quality-selector");
const mod = await import("@videojs-player/vue");
return mod.VideoPlayer;
});
await import('video.js/dist/video-js.css')
await import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css')
await import('videojs-contrib-quality-levels')
await import('videojs-hls-quality-selector')
const mod = await import('@videojs-player/vue')
return mod.VideoPlayer
})
const props = defineProps({
options: {
......@@ -95,7 +101,7 @@ const props = defineProps({
},
debug: {
type: Boolean,
default: false
default: false,
},
/**
* iOS 环境下是否强制使用原生播放器
......@@ -103,14 +109,14 @@ const props = defineProps({
*/
useNativeOnIos: {
type: Boolean,
default: true
}
});
default: true,
},
})
const emit = defineEmits(["onPlay", "onPause"]);
const emit = defineEmits(['onPlay', 'onPause'])
const videoRef = ref(null);
const nativeVideoRef = ref(null);
const videoRef = ref(null)
const nativeVideoRef = ref(null)
const {
player,
......@@ -127,68 +133,137 @@ const {
hlsSpeedDebugText,
retryLoad,
handleVideoJsMounted,
tryNativePlay
} = useVideoPlayer(props, emit, videoRef, nativeVideoRef);
tryNativePlay,
} = useVideoPlayer(props, emit, videoRef, nativeVideoRef)
// 视频加载诊断(控制台日志)
const logVideoDiagnostics = () => {
if (!props.debug) return
const isSameOrigin = (() => {
if (typeof window === 'undefined' || !props.videoUrl) return false
try {
const videoUrl = new URL(props.videoUrl, window.location.href)
return videoUrl.origin === window.location.origin
} catch {
return false
}
})()
// 检查是否为 HLS 视频
const checkIsM3U8 = url => {
if (!url) return false
return url.toLowerCase().includes('.m3u8')
}
console.group('🎬 [VideoPlayer] 视频加载诊断')
console.log('📌 URL:', videoUrlValue.value)
console.log('🔗 同源:', isSameOrigin)
console.log('🎥 播放器类型:', useNativePlayer.value ? '原生播放器' : 'Video.js')
console.log('📦 挂载状态:', state.value ? '已挂载' : '加载中')
console.log('🌐 HLS支持:', checkIsM3U8(videoUrlValue.value) ? '是' : '否')
console.groupEnd()
}
// 监听视频加载状态变化
watch(
() => [state.value, showErrorOverlay.value, errorMessage.value],
([currentState, showError, errorMsg]) => {
if (props.debug) {
console.group('🎬 [VideoPlayer] 状态变化')
console.log('📊 当前状态:', currentState ? '已挂载' : '加载中')
console.log('❌ 错误状态:', showError ? errorMsg : '无')
console.groupEnd()
}
if (showError && props.debug) {
console.group('❌ [VideoPlayer] 错误诊断')
console.error('错误信息:', errorMsg)
console.log('视频URL:', videoUrlValue.value)
console.log('播放器类型:', useNativePlayer.value ? '原生' : 'Video.js')
console.log('排查建议:')
console.log(' 1. 检查 CDN 服务器是否正常运行')
console.log(' 2. 检查 CORS 跨域配置是否正确')
console.log(' 3. 检查网络连接是否稳定')
console.log(' 4. 检查视频文件格式是否支持')
console.groupEnd()
}
// 每次状态变化都输出诊断信息
if (currentState) {
logVideoDiagnostics()
}
},
{ immediate: true }
)
// 事件处理
/**
* @description 处理播放事件
* @param {Event|Object} payload - 事件对象或数据
*/
const handlePlay = (payload) => emit("onPlay", payload);
const handlePlay = payload => {
if (props.debug) {
console.log('🎬 [VideoPlayer] 视频开始播放', payload)
}
emit('onPlay', payload)
}
/**
* @description 处理暂停事件
* @param {Event|Object} payload - 事件对象或数据
*/
const handlePause = (payload) => emit("onPause", payload);
const handlePause = payload => {
if (props.debug) {
console.log('⏸️ [VideoPlayer] 视频暂停', payload)
}
emit('onPause', payload)
}
/**
* @description 处理原生播放事件
* @param {Event} event - 事件对象
*/
const handleNativePlay = (event) => emit("onPlay", event);
const handleNativePlay = event => emit('onPlay', event)
/**
* @description 处理原生暂停事件
* @param {Event} event - 事件对象
*/
const handleNativePause = (event) => emit("onPause", event);
const handleNativePause = event => emit('onPause', event)
// 暴露方法给父组件
defineExpose({
pause() {
if (useNativePlayer.value) {
try {
nativeVideoRef.value?.pause?.();
emit('onPause', nativeVideoRef.value);
} catch (e) {
}
return;
nativeVideoRef.value?.pause?.()
emit('onPause', nativeVideoRef.value)
} catch (e) {}
return
}
if (player.value && !player.value.isDisposed()) {
try {
player.value.pause();
emit('onPause', player.value);
} catch (e) {
}
player.value.pause()
emit('onPause', player.value)
} catch (e) {}
}
},
play() {
if (useNativePlayer.value) {
tryNativePlay();
return;
tryNativePlay()
return
}
player.value?.play()?.catch(() => {});
player.value?.play()?.catch(() => {})
},
getPlayer() {
return useNativePlayer.value ? nativeVideoRef.value : player.value;
return useNativePlayer.value ? nativeVideoRef.value : player.value
},
getId() {
return props.videoId || "meta_id";
return props.videoId || 'meta_id'
},
});
})
</script>
<style scoped>
......@@ -207,7 +282,43 @@ defineExpose({
}
.video-player.loading {
opacity: 0.6;
opacity: 0;
}
/* Loading 占位符样式 */
.loading-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #4caf50;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
margin-top: 12px;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
}
/* 错误覆盖层样式 */
......
......@@ -147,10 +147,21 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const handleError = (code, message = '') => {
showErrorOverlay.value = true
hideNetworkSpeed()
// 调试日志:记录错误
if (props.debug) {
console.group('❌ [VideoPlayer] 播放错误')
console.error('错误代码:', code)
console.error('错误信息:', message || errorMessage.value)
console.error('视频 URL:', videoUrlValue.value)
console.error('重试次数:', `${retryCount.value}/${maxRetries}`)
console.groupEnd()
}
switch (code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = `视频格式不支持或无法加载,请检查网络连接${getErrorHint()}`
// 旧机型/弱网下可能出现短暂的无法加载”,这里做有限次数重试
// 旧机型/弱网下可能出现短暂的无法加载”,这里做有限次数重试
// if (retryCount.value < maxRetries) {
// setTimeout(retryLoad, 1000);
// }
......@@ -161,6 +172,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
case 2: // MEDIA_ERR_NETWORK
errorMessage.value = `网络连接错误,请检查网络后重试${getErrorHint()}`
if (retryCount.value < maxRetries) {
if (props.debug) {
console.log('🔄 [VideoPlayer] 2秒后自动重试...')
}
setTimeout(retryLoad, 2000)
}
break
......@@ -289,13 +303,25 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
html5: {
vhs: {
overrideNative: shouldOverrideNativeHls.value,
// 优化跨域视频加载配置
enableLowInitialPlaylist: true,
// 增加 VHS 超时时间(默认 30 秒,增加到 60 秒)
handleManifestRedirects: true,
// 配置 VHS 超时和重试
blacklistDuration: Infinity,
// 允许重试失败的分段
handleManifestRedirects: true,
},
nativeVideoTracks: false,
nativeAudioTracks: false,
nativeTextTracks: false,
hls: {
withCredentials: false,
// HLS 配置
overrideNative: shouldOverrideNativeHls.value,
},
// 配置原生视频请求超时和重试
requestMediaAccessPermissions: false,
},
techOrder: ['html5'],
userActions: {
......@@ -311,6 +337,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
},
},
},
// 增加网络超时配置(跨域大视频需要更长的超时时间)
// 这会影响 video.js 内部 XHR 的超时
...props.options,
errorDisplay: false,
}
......@@ -325,9 +353,178 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
state.value = payload.state
player.value = payload.player
// 调试日志:Video.js 挂载成功
if (props.debug) {
console.group('🎬 [VideoPlayer] Video.js 挂载成功')
console.log('📦 播放器实例:', player.value)
console.log('🎯 当前视频源:', videoSources.value)
console.log('🌐 是否 HLS:', isM3U8.value)
console.log('🔧 覆盖原生 HLS:', shouldOverrideNativeHls.value)
console.groupEnd()
}
if (player.value) {
setHlsDebug('mounted')
// 添加详细的加载事件监听
if (props.debug) {
console.log('🔧 [VideoPlayer] 开始监听视频加载事件...')
const events = [
'loadstart', // 开始加载
'loadedmetadata', // 元数据加载完成
'loadeddata', // 数据加载完成
'canplay', // 可以播放
'canplaythrough', // 可以流畅播放
'playing', // 正在播放
'waiting', // 等待数据
'stalled', // 网络卡顿
'suspend', // 暂停加载
'progress', // 加载进度
'durationchange', // 时长变化
'volumechange', // 音量变化
'ratechange', // 播放速率变化
]
events.forEach(eventName => {
player.value.on(eventName, () => {
console.log(`📡 [VideoPlayer] 事件触发: ${eventName}`)
})
})
// 监听网络状态变化
player.value.on('networkstate', () => {
try {
const networkState = player.value.currentSrc() ? '已连接' : '未连接'
console.log(`🌐 [VideoPlayer] 网络状态: ${networkState}`)
} catch (e) {
console.log(`🌐 [VideoPlayer] 网络状态变化`)
}
})
// 监听就绪状态
player.value.on('readystate', () => {
try {
const readyState = player.value.readyState()
const states = [
'HAVE_NOTHING',
'HAVE_METADATA',
'HAVE_CURRENT_DATA',
'HAVE_FUTURE_DATA',
'HAVE_ENOUGH_DATA',
]
console.log(`📊 [VideoPlayer] 就绪状态: ${states[readyState] || readyState}`)
} catch (e) {
console.log(`📊 [VideoPlayer] 就绪状态变化`)
}
})
// 监听缓冲进度
const checkBuffer = () => {
try {
const buffered = player.value.buffered()
const duration = player.value.duration() || 0
if (buffered && buffered.length > 0) {
const bufferedEnd = buffered.end(buffered.length - 1)
const bufferedPercent =
duration > 0 ? ((bufferedEnd / duration) * 100).toFixed(2) : '0.00'
console.log(
`📊 [VideoPlayer] 缓冲进度: ${bufferedPercent}% (${bufferedEnd.toFixed(2)}s / ${duration.toFixed(2)}s)`
)
// 检查缓冲是否卡住(5秒内没有增长)
const now = Date.now()
if (!checkBuffer.lastBufferTime) {
checkBuffer.lastBufferTime = now
checkBuffer.lastBufferEnd = bufferedEnd
} else {
const timeDiff = (now - checkBuffer.lastBufferTime) / 1000
const bufferDiff = bufferedEnd - checkBuffer.lastBufferEnd
if (timeDiff > 5 && bufferDiff === 0 && bufferedEnd < duration) {
console.warn(`⚠️ [VideoPlayer] 缓冲已卡住 ${timeDiff.toFixed(1)} 秒!`)
console.warn(
` 当前缓冲: ${bufferedEnd.toFixed(2)}s, 总时长: ${duration.toFixed(2)}s`
)
console.warn(` 可能原因: 网络中断、CDN限制、或视频源问题`)
}
// 如果缓冲有增长,更新时间戳
if (bufferDiff > 0) {
checkBuffer.lastBufferTime = now
checkBuffer.lastBufferEnd = bufferedEnd
}
}
}
} catch (e) {
// 忽略错误
}
}
// 定期检查缓冲进度
const bufferInterval = setInterval(() => {
if (player.value && !player.value.isDisposed()) {
checkBuffer()
} else {
clearInterval(bufferInterval)
}
}, 2000)
// 播放器销毁时清除定时器
const originalDispose = player.value.dispose
player.value.dispose = function () {
clearInterval(bufferInterval)
return originalDispose.call(this)
}
// 立即检查一次
setTimeout(checkBuffer, 100)
// 使用 Performance API 监控网络请求
setTimeout(() => {
try {
const perfEntries = performance.getEntriesByType('resource')
const videoRequests = perfEntries.filter(entry => {
const url = entry.name?.toLowerCase() || ''
return url.includes('.mp4') || url.includes('.m3u8') || url.includes('cdn')
})
if (videoRequests.length > 0) {
console.group('🌐 [VideoPlayer] 网络请求监控')
videoRequests.forEach((req, index) => {
console.log(`请求 #${index + 1}:`)
console.log(' URL:', req.name)
console.log(
' 状态:',
'transferSize' in req
? req.transferSize > 0
? '✅ 成功'
: '⚠️ 可能为空'
: '未知'
)
console.log(' 传输大小:', (req.transferSize / 1024 / 1024).toFixed(2), 'MB')
console.log(' 编码大小:', (req.encodedBodySize / 1024 / 1024).toFixed(2), 'MB')
console.log(' 解码大小:', (req.decodedBodySize / 1024 / 1024).toFixed(2), 'MB')
console.log(' 持续时间:', (req.duration / 1000).toFixed(2), '秒')
console.log(
' 是否完整:',
req.transferSize === req.encodedBodySize ? '是' : '否(可能中断)'
)
// 检查是否被中断
if (req.transferSize > 0 && req.transferSize < req.encodedBodySize) {
console.error('❌ 请求可能被中断!')
}
})
console.groupEnd()
}
} catch (e) {
console.log('🌐 [VideoPlayer] Performance API 不可用')
}
}, 3000) // 3秒后检查请求状态
}
const quality_selector_inited = { value: false }
const setupQualitySelector = () => {
if (quality_selector_inited.value) return
......@@ -424,7 +621,23 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
})
if (props.autoplay) {
player.value.play().catch(() => {})
if (props.debug) {
console.log('▶️ [VideoPlayer] 尝试自动播放')
}
player.value
.play()
.then(() => {
if (props.debug) {
console.log('✅ [VideoPlayer] 自动播放成功')
}
})
.catch(err => {
if (props.debug) {
console.error('❌ [VideoPlayer] 自动播放失败:', err)
console.error(' 失败原因:', err.name)
console.error(' 错误消息:', err.message)
}
})
}
}
}
......@@ -528,6 +741,15 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
)
onMounted(() => {
// 调试日志:组件挂载
if (props.debug) {
console.group('🎬 [VideoPlayer] 组件挂载')
console.log('📹 视频 URL:', videoUrlValue.value)
console.log('🎥 播放器类型:', useNativePlayer.value ? '原生播放器' : 'Video.js')
console.log('🌐 是否 HLS:', isM3U8.value)
console.groupEnd()
}
void probeVideo()
if (useNativePlayer.value) {
initNativePlayer()
......@@ -535,6 +757,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
})
onBeforeUnmount(() => {
// 调试日志:组件卸载
if (props.debug) {
console.log('🗑️ [VideoPlayer] 组件卸载,清理播放器资源')
}
if (retry_error_check_timer) {
clearTimeout(retry_error_check_timer)
retry_error_check_timer = null
......
......@@ -368,6 +368,7 @@
</template>
<VideoPlayer
v-else
:key="`video_${index}_${item.video_url}`"
:video-url="item.video_url"
:video-id="`home_recommend_video_${index}`"
:use-native-on-ios="false"
......@@ -394,6 +395,16 @@ import LiveStreamCard from '@/components/courses/LiveStreamCard.vue'
import VideoPlayer from '@/components/media/VideoPlayer.vue'
import CheckInList from '@/components/checkin/CheckInList.vue'
// Video.js 资源预加载(解决首次播放黑屏问题)
const preloadVideoJs = () => {
// 在用户可能点击视频前,预先加载 Video.js 资源
import('video.js/dist/video-js.css')
import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css')
import('videojs-contrib-quality-levels')
import('videojs-hls-quality-selector')
import('@videojs-player/vue')
}
import FeaturedCoursesSection from '@/components/homePage/FeaturedCoursesSection.vue'
import RecommendationsSection from '@/components/homePage/RecommendationsSection.vue'
import LatestActivitiesSection from '@/components/homePage/LatestActivitiesSection.vue'
......@@ -431,6 +442,9 @@ const activeTab = ref('推荐') // 当前激活的内容标签页
const checkInTypes = ref([])
onMounted(() => {
// 预加载 Video.js 资源,避免首次播放时黑屏
preloadVideoJs()
watch(
() => currentUser.value,
async newVal => {
......
......@@ -3,44 +3,76 @@
* @Description: 学习详情页面
-->
<template>
<div class="study-detail-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20">
<div v-if="course" class="flex flex-col h-screen">
<div class="study-detail-page min-h-screen bg-gradient-to-b from-green-50/70 to-white/90 pb-20">
<div v-if="course" class="flex h-screen flex-col">
<!-- 固定区域:视频播放和标签页 -->
<div class="fixed top-0 left-0 right-0 z-10 top-wrapper">
<div class="top-wrapper fixed left-0 right-0 top-0 z-10">
<!-- 视频播放区域 -->
<div v-if="course.course_type === 'video'" class="w-full relative">
<div v-if="course.course_type === 'video'" class="relative w-full">
<!-- 视频封面和播放按钮 -->
<div v-if="!isPlaying" class="relative w-full" :style="{ aspectRatio: '16/9', backgroundColor: courseFile.cover ? 'transparent' : '#000' }">
<img v-if="courseFile.cover" :src="courseFile.cover" :alt="course.title" class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-center justify-center cursor-pointer"
@click="startPlay">
<div
class="w-20 h-20 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
<font-awesome-icon icon="circle-play" class="text-5xl text-white"
style="font-size: 3rem;" />
v-if="!isPlaying"
class="relative w-full"
:style="{
aspectRatio: '16/9',
backgroundColor: courseFile.cover ? 'transparent' : '#000',
}"
>
<img
v-if="courseFile.cover"
:src="courseFile.cover"
:alt="course.title"
class="h-full w-full object-cover"
/>
<div
class="absolute inset-0 flex cursor-pointer items-center justify-center"
@click="startPlay"
>
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70"
>
<font-awesome-icon
icon="circle-play"
class="text-5xl text-white"
style="font-size: 3rem"
/>
</div>
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer v-if="isPlaying" ref="videoPlayerRef"
:video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''" :autoplay="false"
<VideoPlayer
v-if="isPlaying"
ref="videoPlayerRef"
:video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''"
:autoplay="false"
:video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''"
:use-native-on-ios="false"
@onPlay="handleVideoPlay" @onPause="handleVideoPause" />
:debug="false"
@onPlay="handleVideoPlay"
@onPause="handleVideoPause"
/>
</div>
<div v-if="course.course_type === 'audio'" class="w-full relative border-b border-gray-200">
<div v-if="course.course_type === 'audio'" class="relative w-full border-b border-gray-200">
<!-- 音频播放器 -->
<AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay"
@pause="onAudioPause" />
<AudioPlayer
ref="audioPlayerRef"
v-if="audioList.length"
:songs="audioList"
@play="onAudioPlay"
@pause="onAudioPause"
/>
</div>
<!-- 图片列表展示区域 -->
<div v-if="course.course_type === 'image'" class="w-full relative">
<div v-if="course.course_type === 'image'" class="relative w-full">
<van-swipe class="w-full" :autoplay="0" :show-indicators="true">
<van-swipe-item v-for="(item, index) in courseFile?.list" :key="index"
@click.native="showImagePreview(index)">
<van-image :src="item.url" class="w-full" fit="cover" style="aspect-ratio: 16/9;">
<van-swipe-item
v-for="(item, index) in courseFile?.list"
:key="index"
@click.native="showImagePreview(index)"
>
<van-image :src="item.url" class="w-full" fit="cover" style="aspect-ratio: 16/9">
<template #image>
<img :src="item.url" class="w-full h-full object-cover" />
<img :src="item.url" class="h-full w-full object-cover" />
</template>
</van-image>
</van-swipe-item>
......@@ -68,47 +100,66 @@
</div> -->
<!-- 默认展示区 -->
<div v-if="!course.course_type" class="relative" style="border-bottom: 1px solid #e5e7eb;">
<div class="h-24 bg-white flex items-center justify-center px-4">
<h3 class="text-lg font-medium text-gray-900 truncate">{{ course.title }}</h3>
<div v-if="!course.course_type" class="relative" style="border-bottom: 1px solid #e5e7eb">
<div class="flex h-24 items-center justify-center bg-white px-4">
<h3 class="truncate text-lg font-medium text-gray-900">{{ course.title }}</h3>
</div>
</div>
<!-- 标签页区域 -->
<div class="px-4 py-3 bg-white" style="position: relative;">
<van-tabs v-model:active="activeTab" sticky animated swipeable shrink color="#4caf50"
@change="handleTabChange">
<van-tab title="介绍" name="intro">
</van-tab>
<div class="bg-white px-4 py-3" style="position: relative">
<van-tabs
v-model:active="activeTab"
sticky
animated
swipeable
shrink
color="#4caf50"
@change="handleTabChange"
>
<van-tab title="介绍" name="intro"> </van-tab>
<van-tab :title-style="{ 'min-width': '50%' }" name="comments">
<template #title>评论({{ commentCount }})</template>
</van-tab>
</van-tabs>
<div v-if="task_list.length > 0" @click="goToCheckin"
style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">打卡互动
<div
v-if="task_list.length > 0"
@click="goToCheckin"
style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666"
>
打卡互动
</div>
</div>
</div>
<!-- 滚动区域:介绍和评论内容 -->
<div class="overflow-y-auto flex-1"
:style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }">
<div id="intro" class="py-4 px-4">
<h1 class="text-lg font-bold mb-2">{{ course.title }}</h1>
<div class="text-gray-500 text-sm flex items-center gap-2">
<span>开课时间 {{ course.schedule_time ? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss') :
'暂无'
}}</span>
<div
class="flex-1 overflow-y-auto"
:style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }"
>
<div id="intro" class="px-4 py-4">
<h1 class="mb-2 text-lg font-bold">{{ course.title }}</h1>
<div class="flex items-center gap-2 text-sm text-gray-500">
<span
>开课时间
{{
course.schedule_time
? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss')
: '暂无'
}}</span
>
<!-- <span class="text-gray-300">|</span> -->
<!-- <span>没有字段{{ course.studyCount || 0 }}次学习</span> -->
</div>
<!-- 学习资料入口 -->
<div v-if="course.course_type === 'file' && courseFile?.list && courseFile.list.length > 0"
class="bg-white rounded-lg p-4 mb-4 cursor-pointer hover:bg-gray-50 transition-colors mt-4 shadow-sm"
@click="showMaterialsPopup = true">
<div
v-if="course.course_type === 'file' && courseFile?.list && courseFile.list.length > 0"
class="mb-4 mt-4 cursor-pointer rounded-lg bg-white p-4 shadow-sm transition-colors hover:bg-gray-50"
@click="showMaterialsPopup = true"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-green-600 rounded-lg flex items-center justify-center">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-600">
<van-icon name="notes" class="text-white" size="20" />
</div>
<div>
......@@ -123,23 +174,34 @@
<div class="h-2 bg-gray-100"></div>
<!-- 评论区 -->
<StudyCommentsSection :comment-count="commentCount" :comment-list="commentList"
:popup-comment-list="popupCommentList" :popup-finished="popupFinished"
:bottom-wrapper-height="bottomWrapperHeight" v-model:showCommentPopup="showCommentPopup"
v-model:popupLoading="popupLoading" v-model:popupComment="popupComment" @toggleLike="toggleLike"
@popupLoad="onPopupLoad" @submitPopupComment="submitPopupComment"
@commentDeleted="handleCommentDeleted" />
<StudyCommentsSection
:comment-count="commentCount"
:comment-list="commentList"
:popup-comment-list="popupCommentList"
:popup-finished="popupFinished"
:bottom-wrapper-height="bottomWrapperHeight"
v-model:showCommentPopup="showCommentPopup"
v-model:popupLoading="popupLoading"
v-model:popupComment="popupComment"
@toggleLike="toggleLike"
@popupLoad="onPopupLoad"
@submitPopupComment="submitPopupComment"
@commentDeleted="handleCommentDeleted"
/>
</div>
<!-- 底部操作栏 -->
<div
class="fixed bottom-0 left-0 right-0 bg-white border-t px-4 py-2 flex items-center space-x-4 bottom-wrapper">
<div class="flex-none flex flex-col items-center gap-1 cursor-pointer active:opacity-80"
@click="showCatalog = true">
class="bottom-wrapper fixed bottom-0 left-0 right-0 flex items-center space-x-4 border-t bg-white px-4 py-2"
>
<div
class="flex flex-none cursor-pointer flex-col items-center gap-1 active:opacity-80"
@click="showCatalog = true"
>
<van-icon name="bars" class="text-lg text-gray-600" />
<span class="text-xs text-gray-600">课程目录</span>
</div>
<div class="flex-grow flex-1 min-w-0">
<div class="min-w-0 flex-1 flex-grow">
<!-- <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言"
class="bg-gray-100 rounded-lg !p-0">
<template #input>
......@@ -147,16 +209,26 @@
class="w-full h-full bg-transparent outline-none resize-none" />
</template>
</van-field> -->
<van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入评论"
class="flex-1 bg-gray-100 rounded-lg" />
<van-field
v-model="newComment"
rows="1"
autosize
type="textarea"
placeholder="请输入评论"
class="flex-1 rounded-lg bg-gray-100"
/>
</div>
<van-button type="primary" size="small" @click="submitComment">发送</van-button>
</div>
</div>
<!-- 图片预览组件 -->
<van-image-preview v-model:show="showPreview" :images="previewImages" :show-index="false"
:close-on-click-image="false">
<van-image-preview
v-model:show="showPreview"
:images="previewImages"
:show-index="false"
:close-on-click-image="false"
>
<template #image="{ src, style, onLoad }">
<img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" />
</template>
......@@ -169,8 +241,13 @@
</van-image-preview>
<!-- 课程目录弹窗 -->
<StudyCatalogPopup v-model:showCatalog="showCatalog" :lessons="course_lessons" :course-id="courseId"
:course-type-maps="course_type_maps" @lessonClick="handleLessonClick" />
<StudyCatalogPopup
v-model:showCatalog="showCatalog"
:lessons="course_lessons"
:course-id="courseId"
:course-type-maps="course_type_maps"
@lessonClick="handleLessonClick"
/>
<!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview -->
......@@ -195,71 +272,109 @@
</van-popup>-->
<!-- 音频播放器弹窗 -->
<van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }">
<van-popup
v-model:show="audioShow"
position="bottom"
round
closeable
:style="{ height: '60%', width: '100%' }"
>
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3>
<AudioPlayer v-if="audioShow && audioUrl" :songs="[{ title: audioTitle, url: audioUrl }]"
class="w-full" />
<h3 class="mb-4 text-center text-lg font-medium">{{ audioTitle }}</h3>
<AudioPlayer
v-if="audioShow && audioUrl"
:songs="[{ title: audioTitle, url: audioUrl }]"
class="w-full"
/>
</div>
</van-popup>
<!-- 视频播放器弹窗 -->
<van-popup v-model:show="videoShow" position="center" round closeable
:style="{ width: '95%', maxHeight: '80vh' }" @close="stopPopupVideoPlay">
<van-popup
v-model:show="videoShow"
position="center"
round
closeable
:style="{ width: '95%', maxHeight: '80vh' }"
@close="stopPopupVideoPlay"
>
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">视频预览</h3>
<div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<h3 class="mb-4 text-center text-lg font-medium">视频预览</h3>
<div class="relative w-full overflow-hidden rounded-lg bg-black" style="aspect-ratio: 16/9">
<!-- 视频封面 -->
<div v-show="!isPopupVideoPlaying"
class="absolute inset-0 bg-black flex items-center justify-center cursor-pointer"
@click="startPopupVideoPlay">
<div class="w-16 h-16 bg-white bg-opacity-80 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-black ml-1" fill="currentColor" viewBox="0 0 24 24">
<div
v-show="!isPopupVideoPlaying"
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black"
@click="startPopupVideoPlay"
>
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-white bg-opacity-80"
>
<svg class="ml-1 h-8 w-8 text-black" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer v-show="isPopupVideoPlaying" ref="popupVideoPlayerRef" :video-url="videoUrl"
:video-id="videoTitle" :autoplay="false" :use-native-on-ios="false" class="w-full h-full" @play="handlePopupVideoPlay"
@pause="handlePopupVideoPause" />
<VideoPlayer
v-show="isPopupVideoPlaying"
ref="popupVideoPlayerRef"
:video-url="videoUrl"
:video-id="videoTitle"
:autoplay="false"
:use-native-on-ios="false"
:debug="true"
class="h-full w-full"
@play="handlePopupVideoPlay"
@pause="handlePopupVideoPause"
/>
</div>
</div>
</van-popup>
<!-- 打卡弹窗 -->
<CheckInDialog v-model:show="showCheckInDialog" :items_today="task_list" :items_history="timeout_task_list"
@check-in-success="handleCheckInSuccess" />
<CheckInDialog
v-model:show="showCheckInDialog"
:items_today="task_list"
:items_history="timeout_task_list"
@check-in-success="handleCheckInSuccess"
/>
<!-- 学习资源弹窗 -->
<StudyMaterialsPopup v-model:showMaterialsPopup="showMaterialsPopup" :files="courseFile?.list || []"
@openPdf="showPdf" @openAudio="showAudio" @openVideo="showVideo" @openImage="showImage" />
<StudyMaterialsPopup
v-model:showMaterialsPopup="showMaterialsPopup"
:files="courseFile?.list || []"
@openPdf="showPdf"
@openAudio="showAudio"
@openVideo="showVideo"
@openImage="showImage"
/>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useTitle } from '@vueuse/core';
import VideoPlayer from '@/components/media/VideoPlayer.vue';
import AudioPlayer from '@/components/media/AudioPlayer.vue';
import CheckInDialog from '@/components/checkin/CheckInDialog.vue';
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import VideoPlayer from '@/components/media/VideoPlayer.vue'
import AudioPlayer from '@/components/media/AudioPlayer.vue'
import CheckInDialog from '@/components/checkin/CheckInDialog.vue'
// import OfficeViewer from '@/components/media/OfficeViewer.vue';
import dayjs from 'dayjs';
import { showToast } from 'vant';
import dayjs from 'dayjs'
import { showToast } from 'vant'
import { normalizeCheckinTaskItems } from '@/utils/tools'
import StudyCommentsSection from '@/components/studyDetail/StudyCommentsSection.vue';
import StudyCatalogPopup from '@/components/studyDetail/StudyCatalogPopup.vue';
import StudyMaterialsPopup from '@/components/studyDetail/StudyMaterialsPopup.vue';
import { useStudyComments } from '@/composables/useStudyComments';
import { useStudyRecordTracker } from '@/composables/useStudyRecordTracker';
import StudyCommentsSection from '@/components/studyDetail/StudyCommentsSection.vue'
import StudyCatalogPopup from '@/components/studyDetail/StudyCatalogPopup.vue'
import StudyMaterialsPopup from '@/components/studyDetail/StudyMaterialsPopup.vue'
import { useStudyComments } from '@/composables/useStudyComments'
import { useStudyRecordTracker } from '@/composables/useStudyRecordTracker'
// 导入接口
import { getScheduleCourseAPI, getCourseDetailAPI } from '@/api/course';
import { getScheduleCourseAPI, getCourseDetailAPI } from '@/api/course'
const route = useRoute();
const router = useRouter();
const course = ref(null);
const route = useRoute()
const router = useRouter()
const course = ref(null)
const {
commentCount,
......@@ -274,8 +389,8 @@ const {
toggleLike,
submitComment,
onPopupLoad,
submitPopupComment
} = useStudyComments(course);
submitPopupComment,
} = useStudyComments(course)
/**
* @function handleCommentDeleted
......@@ -283,7 +398,7 @@ const {
* @param {number|string} comment_id - 被删除的评论ID
* @returns {void}
*/
const handleCommentDeleted = (comment_id) => {
const handleCommentDeleted = comment_id => {
const id = String(comment_id || '')
if (!id) return
......@@ -292,97 +407,117 @@ const handleCommentDeleted = (comment_id) => {
commentCount.value = Math.max(0, Number(commentCount.value || 0) - 1)
}
const activeTab = ref('intro');
const showCatalog = ref(false);
const isPlaying = ref(false);
const videoPlayerRef = ref(null);
const audioPlayerRef = ref(null);
const activeTab = ref('intro')
const showCatalog = ref(false)
const isPlaying = ref(false)
const videoPlayerRef = ref(null)
const audioPlayerRef = ref(null)
// 课程目录相关
const course_lessons = ref([]);
const course_lessons = ref([])
const course_type_maps = ref({
video: '视频',
audio: '音频',
image: '图片',
file: '文件',
});
})
// 开始播放视频
const startPlay = async () => {
console.log('StudyDetailPage: startPlay 被调用');
isPlaying.value = true;
console.log('StudyDetailPage: startPlay 被调用')
isPlaying.value = true
// 等待 VideoPlayer 组件挂载并初始化完成
await nextTick();
await nextTick()
// 需要额外等待 VideoPlayer 组件的 handleMounted 完成
// 使用 setTimeout 确保播放器已经初始化
setTimeout(() => {
console.log('StudyDetailPage: videoPlayerRef.value:', videoPlayerRef.value);
console.log('StudyDetailPage: typeof videoPlayerRef.value?.play:', typeof videoPlayerRef.value?.play);
console.log('StudyDetailPage: videoPlayerRef.value:', videoPlayerRef.value)
console.log(
'StudyDetailPage: typeof videoPlayerRef.value?.play:',
typeof videoPlayerRef.value?.play
)
if (videoPlayerRef.value) {
videoPlayerRef.value.play();
console.log('StudyDetailPage: 调用 play() 方法...')
const playResult = videoPlayerRef.value.play()
// play() 可能返回 Promise(Video.js)或 undefined(原生播放器)
if (playResult && typeof playResult.then === 'function') {
playResult
.then(() => {
console.log('✅ StudyDetailPage: play() 成功')
})
.catch(err => {
console.error('❌ StudyDetailPage: play() 失败:', err)
console.error(' 错误名称:', err.name)
console.error(' 错误消息:', err.message)
// 常见原因:
// - NotAllowedError: 用户没有与页面交互
// - NotSupportedError: 视频格式不支持
// - AbortError: 加载被中断
})
} else {
console.log('✅ StudyDetailPage: play() 调用完成(原生播放器)')
}
} else {
console.error('StudyDetailPage: videoPlayerRef.value 不存在');
console.error('StudyDetailPage: videoPlayerRef.value 不存在')
}
}, 300); // 增加延迟,确保播放器初始化完成
};
}, 300) // 增加延迟,确保播放器初始化完成
}
// 处理视频播放状态
const handleVideoPlay = (video) => {
isPlaying.value = true;
const handleVideoPlay = video => {
isPlaying.value = true
// 学习时长埋点开始
startAction();
};
startAction()
}
const handleVideoPause = (video) => {
const handleVideoPause = video => {
// 保持视频播放器可见,只在初始状态显示封面
// 学习时长埋点结束
endAction();
};
endAction()
}
// 弹窗视频播放控制函数
const startPopupVideoPlay = async () => {
isPopupVideoPlaying.value = true;
await nextTick();
isPopupVideoPlaying.value = true
await nextTick()
if (popupVideoPlayerRef.value) {
popupVideoPlayerRef.value.play();
popupVideoPlayerRef.value.play()
}
};
}
const handlePopupVideoPlay = (video) => {
isPopupVideoPlaying.value = true;
};
const handlePopupVideoPlay = video => {
isPopupVideoPlaying.value = true
}
const handlePopupVideoPause = (video) => {
const handlePopupVideoPause = video => {
// 保持视频播放器可见,只在初始状态显示封面
};
}
// 停止弹窗视频播放
const stopPopupVideoPlay = () => {
console.log('停止弹窗视频播放');
console.log('停止弹窗视频播放')
if (popupVideoPlayerRef.value && typeof popupVideoPlayerRef.value.pause === 'function') {
popupVideoPlayerRef.value.pause();
popupVideoPlayerRef.value.pause()
}
isPopupVideoPlaying.value = false;
};
isPopupVideoPlaying.value = false
}
// 图片预览相关
const showPreview = ref(false);
const previewImages = ref([]);
const showPreview = ref(false)
const previewImages = ref([])
// 显示图片预览
const showImagePreview = (startPosition) => {
previewImages.value = courseFile.value?.list?.map(item => item.url) || [];
showPreview.value = true;
};
const showImagePreview = startPosition => {
previewImages.value = courseFile.value?.list?.map(item => item.url) || []
showPreview.value = true
}
// 关闭图片预览
const closeImagePreview = () => {
showPreview.value = false;
};
showPreview.value = false
}
/**
* @function handleCheckInSuccess
......@@ -391,41 +526,41 @@ const closeImagePreview = () => {
*/
const handleCheckInSuccess = () => {
// 打卡成功轻提示
showToast('打卡成功');
};
showToast('打卡成功')
}
const audioList = ref([]);
const audioList = ref([])
// 设置页面标题(动态显示课程名)
const pageTitle = computed(() => course.value?.title || '学习详情');
watch(pageTitle, (newTitle) => useTitle(newTitle), { immediate: true });
const pageTitle = computed(() => course.value?.title || '学习详情')
watch(pageTitle, newTitle => useTitle(newTitle), { immediate: true })
const topWrapperHeight = ref(0);
const bottomWrapperHeight = ref(0);
const topWrapperHeight = ref(0)
const bottomWrapperHeight = ref(0)
// 标记是否由tab切换触发的滚动
const isTabScrolling = ref(false);
const isTabScrolling = ref(false)
const handleScroll = () => {
// 如果是由tab切换触发的滚动,不处理
if (isTabScrolling.value) return;
if (isTabScrolling.value) return
const introElement = document.getElementById('intro');
const commentElement = document.getElementById('comment');
if (!introElement || !commentElement) return;
const introElement = document.getElementById('intro')
const commentElement = document.getElementById('comment')
if (!introElement || !commentElement) return
const scrollTop = window.scrollY;
const commentOffset = commentElement.offsetTop - parseInt(topWrapperHeight.value) - 20; // 20是一个偏移量
const scrollTop = window.scrollY
const commentOffset = commentElement.offsetTop - parseInt(topWrapperHeight.value) - 20 // 20是一个偏移量
// 根据滚动位置更新activeTab
if (scrollTop >= commentOffset) {
activeTab.value = 'comments';
activeTab.value = 'comments'
} else {
activeTab.value = 'intro';
activeTab.value = 'intro'
}
};
}
const courseFile = ref({});
const courseFile = ref({})
/**
* @function sync_study_course_back_position
......@@ -435,52 +570,52 @@ const courseFile = ref({});
* 注释:在用户点击目录项时调用,记录当前滚动位置和目录项ID
*/
const sync_study_course_back_position = (lesson_id) => {
const from_course_id = route.query.from_course_id || '';
if (!from_course_id) return;
const sync_study_course_back_position = lesson_id => {
const from_course_id = route.query.from_course_id || ''
if (!from_course_id) return
const key = `study_course_scroll_${from_course_id}`;
const raw = sessionStorage.getItem(key);
const key = `study_course_scroll_${from_course_id}`
const raw = sessionStorage.getItem(key)
let payload = null;
let payload = null
try {
payload = raw ? JSON.parse(raw) : null;
payload = raw ? JSON.parse(raw) : null
} catch (e) {
payload = null;
payload = null
}
if (!payload || typeof payload !== 'object') {
payload = { scroll_y: 0, lesson_id: '', saved_at: 0 };
payload = { scroll_y: 0, lesson_id: '', saved_at: 0 }
}
payload.lesson_id = lesson_id || payload.lesson_id || '';
payload.saved_at = Date.now();
payload.lesson_id = lesson_id || payload.lesson_id || ''
payload.saved_at = Date.now()
sessionStorage.setItem(key, JSON.stringify(payload));
};
sessionStorage.setItem(key, JSON.stringify(payload))
}
watch(
() => route.params.id,
(val) => {
sync_study_course_back_position(val || '');
val => {
sync_study_course_back_position(val || '')
},
{ immediate: true }
);
)
const handleLessonClick = async (lesson) => {
showCatalog.value = false; // 关闭目录弹窗
isPlaying.value = false; // 重置播放状态
const handleLessonClick = async lesson => {
showCatalog.value = false // 关闭目录弹窗
isPlaying.value = false // 重置播放状态
// 同步学习课程返回位置
sync_study_course_back_position(lesson.id);
sync_study_course_back_position(lesson.id)
// 更新URL地址,不触发页面重新加载
router.replace({ params: { id: lesson.id } });
router.replace({ params: { id: lesson.id } })
// 重新获取课程数据
const { code, data } = await getScheduleCourseAPI({ i: lesson.id });
const { code, data } = await getScheduleCourseAPI({ i: lesson.id })
if (code === 1) {
course.value = data;
courseFile.value = data.file;
course.value = data
courseFile.value = data.file
// 更新音频列表数据
if (data.course_type === 'audio' && data.file?.list?.length) {
......@@ -489,68 +624,71 @@ const handleLessonClick = async (lesson) => {
title: item.title || '未命名音频',
artist: '',
url: item.url,
cover: item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'
}));
cover: item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg',
}))
} else {
audioList.value = [];
audioList.value = []
}
await refreshComments();
await refreshComments()
// 重新计算顶部和底部容器的高度
nextTick(() => {
const topWrapper = document.querySelector('.top-wrapper');
const bottomWrapper = document.querySelector('.bottom-wrapper');
const topWrapper = document.querySelector('.top-wrapper')
const bottomWrapper = document.querySelector('.bottom-wrapper')
if (topWrapper) {
topWrapperHeight.value = topWrapper.clientHeight + 'px';
topWrapperHeight.value = `${topWrapper.clientHeight}px`
}
if (bottomWrapper) {
bottomWrapperHeight.value = bottomWrapper.clientHeight + 'px';
bottomWrapperHeight.value = `${bottomWrapper.clientHeight}px`
}
});
})
// 图片附件或者附件不存在
// 进入后直接执行学习时长埋点
if (course.value?.course_type === 'image' || !course.value?.course_type) {
// 新增记录
let paramsObj = {
const paramsObj = {
schedule_id: courseId.value,
}
addRecord(paramsObj);
addRecord(paramsObj)
}
}
};
}
// Office 文档预览相关
const officeShow = ref(false);
const officeTitle = ref('');
const officeUrl = ref('');
const officeFileType = ref('');
const officeShow = ref(false)
const officeTitle = ref('')
const officeUrl = ref('')
const officeFileType = ref('')
// 音频播放器相关
const audioShow = ref(false);
const audioTitle = ref('');
const audioUrl = ref('');
const audioShow = ref(false)
const audioTitle = ref('')
const audioUrl = ref('')
// 视频播放器相关
const videoShow = ref(false);
const videoTitle = ref('');
const videoUrl = ref('');
const isPopupVideoPlaying = ref(false); // 弹窗视频播放状态
const popupVideoPlayerRef = ref(null); // 弹窗视频播放器引用
const videoShow = ref(false)
const videoTitle = ref('')
const videoUrl = ref('')
const isPopupVideoPlaying = ref(false) // 弹窗视频播放状态
const popupVideoPlayerRef = ref(null) // 弹窗视频播放器引用
const showPdf = ({ title, url, meta_id }) => {
// 跳转到PDF预览页面,并带上返回的课程ID和打开资料弹框的标记
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
router.replace({ name: 'PdfPreview', query: { url: encodedUrl, title: encodedTitle, returnId: courseId.value, openMaterials: '1' } });
const encodedUrl = encodeURIComponent(url)
const encodedTitle = encodeURIComponent(title)
router.replace({
name: 'PdfPreview',
query: { url: encodedUrl, title: encodedTitle, returnId: courseId.value, openMaterials: '1' },
})
// 新增记录
let paramsObj = {
const paramsObj = {
schedule_id: courseId.value,
meta_id
meta_id,
}
addRecord(paramsObj);
};
addRecord(paramsObj)
}
/**
* 显示 Office 文档预览
......@@ -597,138 +735,153 @@ const showPdf = ({ title, url, meta_id }) => {
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showAudio = ({ title, url, meta_id }) => {
audioTitle.value = title;
audioUrl.value = url;
audioShow.value = true;
audioTitle.value = title
audioUrl.value = url
audioShow.value = true
// 新增记录
let paramsObj = {
const paramsObj = {
schedule_id: courseId.value,
meta_id
meta_id,
}
addRecord(paramsObj);
};
addRecord(paramsObj)
}
/**
* 显示视频播放器
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showVideo = ({ title, url, meta_id }) => {
videoTitle.value = title;
videoUrl.value = url;
videoShow.value = true;
isPopupVideoPlaying.value = false; // 重置播放状态
videoTitle.value = title
videoUrl.value = url
videoShow.value = true
isPopupVideoPlaying.value = false // 重置播放状态
// 新增记录
let paramsObj = {
const paramsObj = {
schedule_id: courseId.value,
meta_id
meta_id,
}
addRecord(paramsObj);
};
addRecord(paramsObj)
}
// 监听弹窗关闭,停止视频播放
watch(videoShow, (newVal) => {
watch(videoShow, newVal => {
if (!newVal) {
// 弹窗关闭时停止视频播放
stopPopupVideoPlay();
stopPopupVideoPlay()
}
});
})
/**
* 显示图片预览
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showImage = ({ title, url, meta_id }) => {
previewImages.value = [url];
showPreview.value = true;
previewImages.value = [url]
showPreview.value = true
// 新增记录
let paramsObj = {
const paramsObj = {
schedule_id: courseId.value,
meta_id
meta_id,
}
addRecord(paramsObj);
};
addRecord(paramsObj)
}
const onPdfLoad = (load) => {
const onPdfLoad = load => {
// console.warn('pdf加载状态', load);
};
}
/**
* PDF下载进度回调
* @param {number} progress - 下载进度 0-100
*/
const onPdfProgress = (progress) => {
const onPdfProgress = progress => {
// console.log('PDF下载进度:', progress);
};
}
/**
* PDF下载完成回调
*/
const onPdfComplete = () => {
// console.log('PDF下载完成');
};
}
/**
* Office 文档渲染完成回调
*/
const onOfficeRendered = () => {
console.log('Office 文档渲染完成');
console.log('Office 文档渲染完成')
// showToast('文档加载完成');
};
}
/**
* Office 文档渲染错误回调
* @param {Error} error - 错误对象
*/
const onOfficeError = (error) => {
console.error('Office 文档渲染失败:', error);
showToast('文档加载失败,请重试');
};
const onOfficeError = error => {
console.error('Office 文档渲染失败:', error)
showToast('文档加载失败,请重试')
}
/**
* Office 文档重试回调
*/
const onOfficeRetry = () => {
console.log('重试加载 Office 文档');
console.log('重试加载 Office 文档')
// 可以在这里添加重新获取文档的逻辑
};
}
const courseId = computed(() => {
return route.params.id || '';
});
const courseId = computed(() => route.params.id || '')
const { startAction, endAction, addRecord } = useStudyRecordTracker({
course,
courseId,
videoPlayerRef,
audioPlayerRef
});
audioPlayerRef,
})
/**
* @function preloadVideoJs
* @description 预加载 Video.js 资源,避免首次播放时黑屏
* @returns {void}
*/
const preloadVideoJs = () => {
// 在用户可能点击视频前,预先加载 Video.js 资源
import('video.js/dist/video-js.css')
import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css')
import('videojs-contrib-quality-levels')
import('videojs-hls-quality-selector')
import('@videojs-player/vue')
}
onMounted(async () => {
// 预加载 Video.js 资源,避免首次播放时黑屏
preloadVideoJs()
// 延迟设置topWrapper和bottomWrapper的高度
setTimeout(() => {
nextTick(() => {
const topWrapper = document.querySelector('.top-wrapper');
const bottomWrapper = document.querySelector('.bottom-wrapper');
const topWrapper = document.querySelector('.top-wrapper')
const bottomWrapper = document.querySelector('.bottom-wrapper')
if (topWrapper) {
topWrapperHeight.value = topWrapper.clientHeight + 'px';
topWrapperHeight.value = `${topWrapper.clientHeight}px`
}
if (bottomWrapper) {
bottomWrapperHeight.value = bottomWrapper.clientHeight + 'px';
bottomWrapperHeight.value = `${bottomWrapper.clientHeight}px`
}
// 添加滚动监听
window.addEventListener('scroll', handleScroll);
window.addEventListener('scroll', handleScroll)
})
}, 500);
}, 500)
if (courseId.value) {
const { code, data } = await getScheduleCourseAPI({ i: courseId.value });
const { code, data } = await getScheduleCourseAPI({ i: courseId.value })
if (code === 1) {
course.value = data;
courseFile.value = data.file;
course.value = data
courseFile.value = data.file
// 课程大纲细项打卡互动
task_list.value = [];
timeout_task_list.value = [];
task_list.value = []
timeout_task_list.value = []
// 处理task_list数据格式
task_list.value = normalizeCheckinTaskItems(course.value?.task_list)
......@@ -738,87 +891,90 @@ onMounted(async () => {
// 音频列表处理
if (data.course_type === 'audio') {
audioList.value = data.file.list;
audioList.value = data.file.list
data.file.list.forEach((item, index) => {
item.cover = 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'
})
}
// 刷新评论列表
await refreshComments();
await refreshComments()
// 获取课程信息
const detail = await getCourseDetailAPI({ i: course.value.group_id });
const detail = await getCourseDetailAPI({ i: course.value.group_id })
if (detail.code === 1) {
// 课程目录
course_lessons.value = detail.data.schedule || [];
course_lessons.value = detail.data.schedule || []
}
}
// 图片附件或者附件不存在
// 进入后直接执行学习时长埋点
if (course.value.course_type === 'image' || !course.value.course_type) {
// 新增记录
let paramsObj = {
const paramsObj = {
schedule_id: courseId.value,
}
addRecord(paramsObj);
addRecord(paramsObj)
}
}
});
})
// 处理标签页切换
const handleTabChange = (name) => {
const handleTabChange = name => {
// 先更新activeTab值
activeTab.value = name;
activeTab.value = name
// 设置标记,表示这是由tab切换触发的滚动
isTabScrolling.value = true;
isTabScrolling.value = true
// 然后执行滚动操作
nextTick(() => {
const element = document.getElementById(name === 'intro' ? 'intro' : 'comment');
const element = document.getElementById(name === 'intro' ? 'intro' : 'comment')
if (element) {
const topOffset = element.offsetTop - parseInt(topWrapperHeight.value);
const topOffset = element.offsetTop - parseInt(topWrapperHeight.value)
window.scrollTo({
top: topOffset,
behavior: 'smooth'
});
behavior: 'smooth',
})
// 滚动动画结束后重置标记
setTimeout(() => {
isTabScrolling.value = false;
}, 500); // 假设滚动动画持续500ms
isTabScrolling.value = false
}, 500) // 假设滚动动画持续500ms
} else {
isTabScrolling.value = false;
isTabScrolling.value = false
}
});
};
})
}
// 在组件卸载时移除滚动监听
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
endAction();
});
window.removeEventListener('scroll', handleScroll)
endAction()
})
// 学习资料弹窗状态
const showMaterialsPopup = ref(false);
const showMaterialsPopup = ref(false)
// 路由参数监听:如果openMaterials=1,则打开学习资料弹框
watch(() => route.query.openMaterials, (val) => {
watch(
() => route.query.openMaterials,
val => {
if (val === '1' || val === 1 || val === true) {
showMaterialsPopup.value = true;
showMaterialsPopup.value = true
}
}, { immediate: true });
},
{ immediate: true }
)
// 监听弹框关闭:关闭时移除URL中的openMaterials参数,防止刷新再次打开
watch(showMaterialsPopup, (val, oldVal) => {
if (oldVal && !val) {
const newQuery = { ...route.query };
delete newQuery.openMaterials;
router.replace({ path: route.path, query: newQuery });
const newQuery = { ...route.query }
delete newQuery.openMaterials
router.replace({ path: route.path, query: newQuery })
}
});
})
/**
* 在新窗口中打开文件
......@@ -851,7 +1007,6 @@ watch(showMaterialsPopup, (val, oldVal) => {
// return supportedTypes.includes(extension);
// }
/**
* 判断文件是否为 Office 文档
* @param {string} fileName - 文件名
......@@ -895,53 +1050,53 @@ watch(showMaterialsPopup, (val, oldVal) => {
* @param audio 音频对象
*/
const onAudioPlay = (audio, meta_id) => {
console.log('开始播放音频', audio);
console.log('开始播放音频', audio)
// 学习时长埋点开始
startAction({ meta_id });
startAction({ meta_id })
}
/**
* 音频暂停事件
* @param audio 音频对象
*/
const onAudioPause = (audio) => {
console.log('暂停播放音频', audio);
const onAudioPause = audio => {
console.log('暂停播放音频', audio)
// 学习时长埋点结束
endAction();
endAction()
}
// 打卡相关状态
const showCheckInDialog = ref(false);
const task_list = ref([]);
const timeout_task_list = ref([]);
const showCheckInDialog = ref(false)
const task_list = ref([])
const timeout_task_list = ref([])
// 统一弹窗后不再维护默认列表与切换状态
// 处理打卡选择
const goToCheckin = () => {
if (!(task_list.value.length || timeout_task_list.value.length)) {
showToast('暂无打卡任务');
return;
showToast('暂无打卡任务')
return
}
showCheckInDialog.value = true;
};
showCheckInDialog.value = true
}
/**
* 格式化文件大小
* @param {number} size - 文件大小(字节)
* @returns {string} 格式化后的文件大小
*/
const formatFileSize = (size) => {
if (!size) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let index = 0;
let fileSize = size;
const formatFileSize = size => {
if (!size) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let index = 0
let fileSize = size
while (fileSize >= 1024 && index < units.length - 1) {
fileSize /= 1024;
index++;
fileSize /= 1024
index++
}
return `${fileSize.toFixed(1)} ${units[index]}`;
return `${fileSize.toFixed(1)} ${units[index]}`
}
</script>
......