fix(video): 预加载Video.js资源并优化视频播放诊断
- 在HomePage和StudyDetailPage添加Video.js资源预加载函数,解决首次播放黑屏问题 - 为VideoPlayer组件添加加载状态占位符,改善用户体验 - 增强视频播放错误处理和调试日志,便于问题排查 - 修复play()方法调用时的Promise处理,避免静默失败 - 优化跨域视频加载配置和网络超时设置
Showing
4 changed files
with
874 additions
and
367 deletions
| ... | @@ -15,6 +15,7 @@ | ... | @@ -15,6 +15,7 @@ |
| 15 | x5-video-player-type="h5" | 15 | x5-video-player-type="h5" |
| 16 | x5-video-player-fullscreen="true" | 16 | x5-video-player-fullscreen="true" |
| 17 | preload="metadata" | 17 | preload="metadata" |
| 18 | + crossorigin="anonymous" | ||
| 18 | @play="handleNativePlay" | 19 | @play="handleNativePlay" |
| 19 | @pause="handleNativePause" | 20 | @pause="handleNativePause" |
| 20 | /> | 21 | /> |
| ... | @@ -31,6 +32,12 @@ | ... | @@ -31,6 +32,12 @@ |
| 31 | @pause="handlePause" | 32 | @pause="handlePause" |
| 32 | /> | 33 | /> |
| 33 | 34 | ||
| 35 | + <!-- Loading 占位符:Video.js 资源加载时显示 --> | ||
| 36 | + <div v-if="!useNativePlayer && !state" class="loading-placeholder"> | ||
| 37 | + <div class="loading-spinner"></div> | ||
| 38 | + <div class="loading-text">视频加载中...</div> | ||
| 39 | + </div> | ||
| 40 | + | ||
| 34 | <!-- <div v-if="showNetworkSpeedOverlay && !showErrorOverlay" class="speed-overlay"> | 41 | <!-- <div v-if="showNetworkSpeedOverlay && !showErrorOverlay" class="speed-overlay"> |
| 35 | <div class="speed-content"> | 42 | <div class="speed-content"> |
| 36 | <div | 43 | <div |
| ... | @@ -45,9 +52,8 @@ | ... | @@ -45,9 +52,8 @@ |
| 45 | </div> | 52 | </div> |
| 46 | </div> --> | 53 | </div> --> |
| 47 | 54 | ||
| 48 | - <div v-if="hlsDownloadSpeedText" class="hls-speed-badge">{{ hlsDownloadSpeedText }}</div> | 55 | + <div v-if="hlsDownloadSpeedText && !props.debug" class="hls-speed-badge"> |
| 49 | - <div v-if="props.debug" class="hls-debug-badge"> | 56 | + {{ hlsDownloadSpeedText }} |
| 50 | - {{ hlsSpeedDebugText || "debug:empty" }} | ||
| 51 | </div> | 57 | </div> |
| 52 | 58 | ||
| 53 | <!-- 错误提示覆盖层 --> | 59 | <!-- 错误提示覆盖层 --> |
| ... | @@ -62,17 +68,17 @@ | ... | @@ -62,17 +68,17 @@ |
| 62 | </template> | 68 | </template> |
| 63 | 69 | ||
| 64 | <script setup> | 70 | <script setup> |
| 65 | -import { defineAsyncComponent, ref } from "vue"; | 71 | +import { defineAsyncComponent, ref, computed, watch } from 'vue' |
| 66 | -import { useVideoPlayer } from "@/composables/useVideoPlayer"; | 72 | +import { useVideoPlayer } from '@/composables/useVideoPlayer' |
| 67 | 73 | ||
| 68 | const VideoPlayer = defineAsyncComponent(async () => { | 74 | const VideoPlayer = defineAsyncComponent(async () => { |
| 69 | - await import("video.js/dist/video-js.css"); | 75 | + await import('video.js/dist/video-js.css') |
| 70 | - await import("videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css"); | 76 | + await import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css') |
| 71 | - await import("videojs-contrib-quality-levels"); | 77 | + await import('videojs-contrib-quality-levels') |
| 72 | - await import("videojs-hls-quality-selector"); | 78 | + await import('videojs-hls-quality-selector') |
| 73 | - const mod = await import("@videojs-player/vue"); | 79 | + const mod = await import('@videojs-player/vue') |
| 74 | - return mod.VideoPlayer; | 80 | + return mod.VideoPlayer |
| 75 | -}); | 81 | +}) |
| 76 | 82 | ||
| 77 | const props = defineProps({ | 83 | const props = defineProps({ |
| 78 | options: { | 84 | options: { |
| ... | @@ -95,7 +101,7 @@ const props = defineProps({ | ... | @@ -95,7 +101,7 @@ const props = defineProps({ |
| 95 | }, | 101 | }, |
| 96 | debug: { | 102 | debug: { |
| 97 | type: Boolean, | 103 | type: Boolean, |
| 98 | - default: false | 104 | + default: false, |
| 99 | }, | 105 | }, |
| 100 | /** | 106 | /** |
| 101 | * iOS 环境下是否强制使用原生播放器 | 107 | * iOS 环境下是否强制使用原生播放器 |
| ... | @@ -103,14 +109,14 @@ const props = defineProps({ | ... | @@ -103,14 +109,14 @@ const props = defineProps({ |
| 103 | */ | 109 | */ |
| 104 | useNativeOnIos: { | 110 | useNativeOnIos: { |
| 105 | type: Boolean, | 111 | type: Boolean, |
| 106 | - default: true | 112 | + default: true, |
| 107 | - } | 113 | + }, |
| 108 | -}); | 114 | +}) |
| 109 | 115 | ||
| 110 | -const emit = defineEmits(["onPlay", "onPause"]); | 116 | +const emit = defineEmits(['onPlay', 'onPause']) |
| 111 | 117 | ||
| 112 | -const videoRef = ref(null); | 118 | +const videoRef = ref(null) |
| 113 | -const nativeVideoRef = ref(null); | 119 | +const nativeVideoRef = ref(null) |
| 114 | 120 | ||
| 115 | const { | 121 | const { |
| 116 | player, | 122 | player, |
| ... | @@ -127,68 +133,137 @@ const { | ... | @@ -127,68 +133,137 @@ const { |
| 127 | hlsSpeedDebugText, | 133 | hlsSpeedDebugText, |
| 128 | retryLoad, | 134 | retryLoad, |
| 129 | handleVideoJsMounted, | 135 | handleVideoJsMounted, |
| 130 | - tryNativePlay | 136 | + tryNativePlay, |
| 131 | -} = useVideoPlayer(props, emit, videoRef, nativeVideoRef); | 137 | +} = useVideoPlayer(props, emit, videoRef, nativeVideoRef) |
| 138 | + | ||
| 139 | +// 视频加载诊断(控制台日志) | ||
| 140 | +const logVideoDiagnostics = () => { | ||
| 141 | + if (!props.debug) return | ||
| 142 | + | ||
| 143 | + const isSameOrigin = (() => { | ||
| 144 | + if (typeof window === 'undefined' || !props.videoUrl) return false | ||
| 145 | + try { | ||
| 146 | + const videoUrl = new URL(props.videoUrl, window.location.href) | ||
| 147 | + return videoUrl.origin === window.location.origin | ||
| 148 | + } catch { | ||
| 149 | + return false | ||
| 150 | + } | ||
| 151 | + })() | ||
| 152 | + | ||
| 153 | + // 检查是否为 HLS 视频 | ||
| 154 | + const checkIsM3U8 = url => { | ||
| 155 | + if (!url) return false | ||
| 156 | + return url.toLowerCase().includes('.m3u8') | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + console.group('🎬 [VideoPlayer] 视频加载诊断') | ||
| 160 | + console.log('📌 URL:', videoUrlValue.value) | ||
| 161 | + console.log('🔗 同源:', isSameOrigin) | ||
| 162 | + console.log('🎥 播放器类型:', useNativePlayer.value ? '原生播放器' : 'Video.js') | ||
| 163 | + console.log('📦 挂载状态:', state.value ? '已挂载' : '加载中') | ||
| 164 | + console.log('🌐 HLS支持:', checkIsM3U8(videoUrlValue.value) ? '是' : '否') | ||
| 165 | + console.groupEnd() | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +// 监听视频加载状态变化 | ||
| 169 | +watch( | ||
| 170 | + () => [state.value, showErrorOverlay.value, errorMessage.value], | ||
| 171 | + ([currentState, showError, errorMsg]) => { | ||
| 172 | + if (props.debug) { | ||
| 173 | + console.group('🎬 [VideoPlayer] 状态变化') | ||
| 174 | + console.log('📊 当前状态:', currentState ? '已挂载' : '加载中') | ||
| 175 | + console.log('❌ 错误状态:', showError ? errorMsg : '无') | ||
| 176 | + console.groupEnd() | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + if (showError && props.debug) { | ||
| 180 | + console.group('❌ [VideoPlayer] 错误诊断') | ||
| 181 | + console.error('错误信息:', errorMsg) | ||
| 182 | + console.log('视频URL:', videoUrlValue.value) | ||
| 183 | + console.log('播放器类型:', useNativePlayer.value ? '原生' : 'Video.js') | ||
| 184 | + console.log('排查建议:') | ||
| 185 | + console.log(' 1. 检查 CDN 服务器是否正常运行') | ||
| 186 | + console.log(' 2. 检查 CORS 跨域配置是否正确') | ||
| 187 | + console.log(' 3. 检查网络连接是否稳定') | ||
| 188 | + console.log(' 4. 检查视频文件格式是否支持') | ||
| 189 | + console.groupEnd() | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + // 每次状态变化都输出诊断信息 | ||
| 193 | + if (currentState) { | ||
| 194 | + logVideoDiagnostics() | ||
| 195 | + } | ||
| 196 | + }, | ||
| 197 | + { immediate: true } | ||
| 198 | +) | ||
| 132 | 199 | ||
| 133 | // 事件处理 | 200 | // 事件处理 |
| 134 | /** | 201 | /** |
| 135 | * @description 处理播放事件 | 202 | * @description 处理播放事件 |
| 136 | * @param {Event|Object} payload - 事件对象或数据 | 203 | * @param {Event|Object} payload - 事件对象或数据 |
| 137 | */ | 204 | */ |
| 138 | -const handlePlay = (payload) => emit("onPlay", payload); | 205 | +const handlePlay = payload => { |
| 206 | + if (props.debug) { | ||
| 207 | + console.log('🎬 [VideoPlayer] 视频开始播放', payload) | ||
| 208 | + } | ||
| 209 | + emit('onPlay', payload) | ||
| 210 | +} | ||
| 139 | 211 | ||
| 140 | /** | 212 | /** |
| 141 | * @description 处理暂停事件 | 213 | * @description 处理暂停事件 |
| 142 | * @param {Event|Object} payload - 事件对象或数据 | 214 | * @param {Event|Object} payload - 事件对象或数据 |
| 143 | */ | 215 | */ |
| 144 | -const handlePause = (payload) => emit("onPause", payload); | 216 | +const handlePause = payload => { |
| 217 | + if (props.debug) { | ||
| 218 | + console.log('⏸️ [VideoPlayer] 视频暂停', payload) | ||
| 219 | + } | ||
| 220 | + emit('onPause', payload) | ||
| 221 | +} | ||
| 145 | 222 | ||
| 146 | /** | 223 | /** |
| 147 | * @description 处理原生播放事件 | 224 | * @description 处理原生播放事件 |
| 148 | * @param {Event} event - 事件对象 | 225 | * @param {Event} event - 事件对象 |
| 149 | */ | 226 | */ |
| 150 | -const handleNativePlay = (event) => emit("onPlay", event); | 227 | +const handleNativePlay = event => emit('onPlay', event) |
| 151 | 228 | ||
| 152 | /** | 229 | /** |
| 153 | * @description 处理原生暂停事件 | 230 | * @description 处理原生暂停事件 |
| 154 | * @param {Event} event - 事件对象 | 231 | * @param {Event} event - 事件对象 |
| 155 | */ | 232 | */ |
| 156 | -const handleNativePause = (event) => emit("onPause", event); | 233 | +const handleNativePause = event => emit('onPause', event) |
| 157 | 234 | ||
| 158 | // 暴露方法给父组件 | 235 | // 暴露方法给父组件 |
| 159 | defineExpose({ | 236 | defineExpose({ |
| 160 | pause() { | 237 | pause() { |
| 161 | if (useNativePlayer.value) { | 238 | if (useNativePlayer.value) { |
| 162 | try { | 239 | try { |
| 163 | - nativeVideoRef.value?.pause?.(); | 240 | + nativeVideoRef.value?.pause?.() |
| 164 | - emit('onPause', nativeVideoRef.value); | 241 | + emit('onPause', nativeVideoRef.value) |
| 165 | - } catch (e) { | 242 | + } catch (e) {} |
| 166 | - } | 243 | + return |
| 167 | - return; | ||
| 168 | } | 244 | } |
| 169 | 245 | ||
| 170 | if (player.value && !player.value.isDisposed()) { | 246 | if (player.value && !player.value.isDisposed()) { |
| 171 | try { | 247 | try { |
| 172 | - player.value.pause(); | 248 | + player.value.pause() |
| 173 | - emit('onPause', player.value); | 249 | + emit('onPause', player.value) |
| 174 | - } catch (e) { | 250 | + } catch (e) {} |
| 175 | - } | ||
| 176 | } | 251 | } |
| 177 | }, | 252 | }, |
| 178 | play() { | 253 | play() { |
| 179 | if (useNativePlayer.value) { | 254 | if (useNativePlayer.value) { |
| 180 | - tryNativePlay(); | 255 | + tryNativePlay() |
| 181 | - return; | 256 | + return |
| 182 | } | 257 | } |
| 183 | - player.value?.play()?.catch(() => {}); | 258 | + player.value?.play()?.catch(() => {}) |
| 184 | }, | 259 | }, |
| 185 | getPlayer() { | 260 | getPlayer() { |
| 186 | - return useNativePlayer.value ? nativeVideoRef.value : player.value; | 261 | + return useNativePlayer.value ? nativeVideoRef.value : player.value |
| 187 | }, | 262 | }, |
| 188 | getId() { | 263 | getId() { |
| 189 | - return props.videoId || "meta_id"; | 264 | + return props.videoId || 'meta_id' |
| 190 | }, | 265 | }, |
| 191 | -}); | 266 | +}) |
| 192 | </script> | 267 | </script> |
| 193 | 268 | ||
| 194 | <style scoped> | 269 | <style scoped> |
| ... | @@ -207,7 +282,43 @@ defineExpose({ | ... | @@ -207,7 +282,43 @@ defineExpose({ |
| 207 | } | 282 | } |
| 208 | 283 | ||
| 209 | .video-player.loading { | 284 | .video-player.loading { |
| 210 | - opacity: 0.6; | 285 | + opacity: 0; |
| 286 | +} | ||
| 287 | + | ||
| 288 | +/* Loading 占位符样式 */ | ||
| 289 | +.loading-placeholder { | ||
| 290 | + position: absolute; | ||
| 291 | + top: 0; | ||
| 292 | + left: 0; | ||
| 293 | + right: 0; | ||
| 294 | + bottom: 0; | ||
| 295 | + background: #000; | ||
| 296 | + display: flex; | ||
| 297 | + flex-direction: column; | ||
| 298 | + align-items: center; | ||
| 299 | + justify-content: center; | ||
| 300 | + z-index: 1; | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +.loading-spinner { | ||
| 304 | + width: 40px; | ||
| 305 | + height: 40px; | ||
| 306 | + border: 3px solid rgba(255, 255, 255, 0.3); | ||
| 307 | + border-top-color: #4caf50; | ||
| 308 | + border-radius: 50%; | ||
| 309 | + animation: spin 1s linear infinite; | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +@keyframes spin { | ||
| 313 | + to { | ||
| 314 | + transform: rotate(360deg); | ||
| 315 | + } | ||
| 316 | +} | ||
| 317 | + | ||
| 318 | +.loading-text { | ||
| 319 | + margin-top: 12px; | ||
| 320 | + color: rgba(255, 255, 255, 0.8); | ||
| 321 | + font-size: 14px; | ||
| 211 | } | 322 | } |
| 212 | 323 | ||
| 213 | /* 错误覆盖层样式 */ | 324 | /* 错误覆盖层样式 */ | ... | ... |
| ... | @@ -147,10 +147,21 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -147,10 +147,21 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 147 | const handleError = (code, message = '') => { | 147 | const handleError = (code, message = '') => { |
| 148 | showErrorOverlay.value = true | 148 | showErrorOverlay.value = true |
| 149 | hideNetworkSpeed() | 149 | hideNetworkSpeed() |
| 150 | + | ||
| 151 | + // 调试日志:记录错误 | ||
| 152 | + if (props.debug) { | ||
| 153 | + console.group('❌ [VideoPlayer] 播放错误') | ||
| 154 | + console.error('错误代码:', code) | ||
| 155 | + console.error('错误信息:', message || errorMessage.value) | ||
| 156 | + console.error('视频 URL:', videoUrlValue.value) | ||
| 157 | + console.error('重试次数:', `${retryCount.value}/${maxRetries}`) | ||
| 158 | + console.groupEnd() | ||
| 159 | + } | ||
| 160 | + | ||
| 150 | switch (code) { | 161 | switch (code) { |
| 151 | case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED | 162 | case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED |
| 152 | errorMessage.value = `视频格式不支持或无法加载,请检查网络连接${getErrorHint()}` | 163 | errorMessage.value = `视频格式不支持或无法加载,请检查网络连接${getErrorHint()}` |
| 153 | - // 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试 | 164 | + // 旧机型/弱网下可能出现短暂的”无法加载”,这里做有限次数重试 |
| 154 | // if (retryCount.value < maxRetries) { | 165 | // if (retryCount.value < maxRetries) { |
| 155 | // setTimeout(retryLoad, 1000); | 166 | // setTimeout(retryLoad, 1000); |
| 156 | // } | 167 | // } |
| ... | @@ -161,6 +172,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -161,6 +172,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 161 | case 2: // MEDIA_ERR_NETWORK | 172 | case 2: // MEDIA_ERR_NETWORK |
| 162 | errorMessage.value = `网络连接错误,请检查网络后重试${getErrorHint()}` | 173 | errorMessage.value = `网络连接错误,请检查网络后重试${getErrorHint()}` |
| 163 | if (retryCount.value < maxRetries) { | 174 | if (retryCount.value < maxRetries) { |
| 175 | + if (props.debug) { | ||
| 176 | + console.log('🔄 [VideoPlayer] 2秒后自动重试...') | ||
| 177 | + } | ||
| 164 | setTimeout(retryLoad, 2000) | 178 | setTimeout(retryLoad, 2000) |
| 165 | } | 179 | } |
| 166 | break | 180 | break |
| ... | @@ -289,13 +303,25 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -289,13 +303,25 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 289 | html5: { | 303 | html5: { |
| 290 | vhs: { | 304 | vhs: { |
| 291 | overrideNative: shouldOverrideNativeHls.value, | 305 | overrideNative: shouldOverrideNativeHls.value, |
| 306 | + // 优化跨域视频加载配置 | ||
| 307 | + enableLowInitialPlaylist: true, | ||
| 308 | + // 增加 VHS 超时时间(默认 30 秒,增加到 60 秒) | ||
| 309 | + handleManifestRedirects: true, | ||
| 310 | + // 配置 VHS 超时和重试 | ||
| 311 | + blacklistDuration: Infinity, | ||
| 312 | + // 允许重试失败的分段 | ||
| 313 | + handleManifestRedirects: true, | ||
| 292 | }, | 314 | }, |
| 293 | nativeVideoTracks: false, | 315 | nativeVideoTracks: false, |
| 294 | nativeAudioTracks: false, | 316 | nativeAudioTracks: false, |
| 295 | nativeTextTracks: false, | 317 | nativeTextTracks: false, |
| 296 | hls: { | 318 | hls: { |
| 297 | withCredentials: false, | 319 | withCredentials: false, |
| 320 | + // HLS 配置 | ||
| 321 | + overrideNative: shouldOverrideNativeHls.value, | ||
| 298 | }, | 322 | }, |
| 323 | + // 配置原生视频请求超时和重试 | ||
| 324 | + requestMediaAccessPermissions: false, | ||
| 299 | }, | 325 | }, |
| 300 | techOrder: ['html5'], | 326 | techOrder: ['html5'], |
| 301 | userActions: { | 327 | userActions: { |
| ... | @@ -311,6 +337,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -311,6 +337,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 311 | }, | 337 | }, |
| 312 | }, | 338 | }, |
| 313 | }, | 339 | }, |
| 340 | + // 增加网络超时配置(跨域大视频需要更长的超时时间) | ||
| 341 | + // 这会影响 video.js 内部 XHR 的超时 | ||
| 314 | ...props.options, | 342 | ...props.options, |
| 315 | errorDisplay: false, | 343 | errorDisplay: false, |
| 316 | } | 344 | } |
| ... | @@ -325,9 +353,178 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -325,9 +353,178 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 325 | state.value = payload.state | 353 | state.value = payload.state |
| 326 | player.value = payload.player | 354 | player.value = payload.player |
| 327 | 355 | ||
| 356 | + // 调试日志:Video.js 挂载成功 | ||
| 357 | + if (props.debug) { | ||
| 358 | + console.group('🎬 [VideoPlayer] Video.js 挂载成功') | ||
| 359 | + console.log('📦 播放器实例:', player.value) | ||
| 360 | + console.log('🎯 当前视频源:', videoSources.value) | ||
| 361 | + console.log('🌐 是否 HLS:', isM3U8.value) | ||
| 362 | + console.log('🔧 覆盖原生 HLS:', shouldOverrideNativeHls.value) | ||
| 363 | + console.groupEnd() | ||
| 364 | + } | ||
| 365 | + | ||
| 328 | if (player.value) { | 366 | if (player.value) { |
| 329 | setHlsDebug('mounted') | 367 | setHlsDebug('mounted') |
| 330 | 368 | ||
| 369 | + // 添加详细的加载事件监听 | ||
| 370 | + if (props.debug) { | ||
| 371 | + console.log('🔧 [VideoPlayer] 开始监听视频加载事件...') | ||
| 372 | + | ||
| 373 | + const events = [ | ||
| 374 | + 'loadstart', // 开始加载 | ||
| 375 | + 'loadedmetadata', // 元数据加载完成 | ||
| 376 | + 'loadeddata', // 数据加载完成 | ||
| 377 | + 'canplay', // 可以播放 | ||
| 378 | + 'canplaythrough', // 可以流畅播放 | ||
| 379 | + 'playing', // 正在播放 | ||
| 380 | + 'waiting', // 等待数据 | ||
| 381 | + 'stalled', // 网络卡顿 | ||
| 382 | + 'suspend', // 暂停加载 | ||
| 383 | + 'progress', // 加载进度 | ||
| 384 | + 'durationchange', // 时长变化 | ||
| 385 | + 'volumechange', // 音量变化 | ||
| 386 | + 'ratechange', // 播放速率变化 | ||
| 387 | + ] | ||
| 388 | + | ||
| 389 | + events.forEach(eventName => { | ||
| 390 | + player.value.on(eventName, () => { | ||
| 391 | + console.log(`📡 [VideoPlayer] 事件触发: ${eventName}`) | ||
| 392 | + }) | ||
| 393 | + }) | ||
| 394 | + | ||
| 395 | + // 监听网络状态变化 | ||
| 396 | + player.value.on('networkstate', () => { | ||
| 397 | + try { | ||
| 398 | + const networkState = player.value.currentSrc() ? '已连接' : '未连接' | ||
| 399 | + console.log(`🌐 [VideoPlayer] 网络状态: ${networkState}`) | ||
| 400 | + } catch (e) { | ||
| 401 | + console.log(`🌐 [VideoPlayer] 网络状态变化`) | ||
| 402 | + } | ||
| 403 | + }) | ||
| 404 | + | ||
| 405 | + // 监听就绪状态 | ||
| 406 | + player.value.on('readystate', () => { | ||
| 407 | + try { | ||
| 408 | + const readyState = player.value.readyState() | ||
| 409 | + const states = [ | ||
| 410 | + 'HAVE_NOTHING', | ||
| 411 | + 'HAVE_METADATA', | ||
| 412 | + 'HAVE_CURRENT_DATA', | ||
| 413 | + 'HAVE_FUTURE_DATA', | ||
| 414 | + 'HAVE_ENOUGH_DATA', | ||
| 415 | + ] | ||
| 416 | + console.log(`📊 [VideoPlayer] 就绪状态: ${states[readyState] || readyState}`) | ||
| 417 | + } catch (e) { | ||
| 418 | + console.log(`📊 [VideoPlayer] 就绪状态变化`) | ||
| 419 | + } | ||
| 420 | + }) | ||
| 421 | + | ||
| 422 | + // 监听缓冲进度 | ||
| 423 | + const checkBuffer = () => { | ||
| 424 | + try { | ||
| 425 | + const buffered = player.value.buffered() | ||
| 426 | + const duration = player.value.duration() || 0 | ||
| 427 | + | ||
| 428 | + if (buffered && buffered.length > 0) { | ||
| 429 | + const bufferedEnd = buffered.end(buffered.length - 1) | ||
| 430 | + const bufferedPercent = | ||
| 431 | + duration > 0 ? ((bufferedEnd / duration) * 100).toFixed(2) : '0.00' | ||
| 432 | + console.log( | ||
| 433 | + `📊 [VideoPlayer] 缓冲进度: ${bufferedPercent}% (${bufferedEnd.toFixed(2)}s / ${duration.toFixed(2)}s)` | ||
| 434 | + ) | ||
| 435 | + | ||
| 436 | + // 检查缓冲是否卡住(5秒内没有增长) | ||
| 437 | + const now = Date.now() | ||
| 438 | + if (!checkBuffer.lastBufferTime) { | ||
| 439 | + checkBuffer.lastBufferTime = now | ||
| 440 | + checkBuffer.lastBufferEnd = bufferedEnd | ||
| 441 | + } else { | ||
| 442 | + const timeDiff = (now - checkBuffer.lastBufferTime) / 1000 | ||
| 443 | + const bufferDiff = bufferedEnd - checkBuffer.lastBufferEnd | ||
| 444 | + | ||
| 445 | + if (timeDiff > 5 && bufferDiff === 0 && bufferedEnd < duration) { | ||
| 446 | + console.warn(`⚠️ [VideoPlayer] 缓冲已卡住 ${timeDiff.toFixed(1)} 秒!`) | ||
| 447 | + console.warn( | ||
| 448 | + ` 当前缓冲: ${bufferedEnd.toFixed(2)}s, 总时长: ${duration.toFixed(2)}s` | ||
| 449 | + ) | ||
| 450 | + console.warn(` 可能原因: 网络中断、CDN限制、或视频源问题`) | ||
| 451 | + } | ||
| 452 | + | ||
| 453 | + // 如果缓冲有增长,更新时间戳 | ||
| 454 | + if (bufferDiff > 0) { | ||
| 455 | + checkBuffer.lastBufferTime = now | ||
| 456 | + checkBuffer.lastBufferEnd = bufferedEnd | ||
| 457 | + } | ||
| 458 | + } | ||
| 459 | + } | ||
| 460 | + } catch (e) { | ||
| 461 | + // 忽略错误 | ||
| 462 | + } | ||
| 463 | + } | ||
| 464 | + | ||
| 465 | + // 定期检查缓冲进度 | ||
| 466 | + const bufferInterval = setInterval(() => { | ||
| 467 | + if (player.value && !player.value.isDisposed()) { | ||
| 468 | + checkBuffer() | ||
| 469 | + } else { | ||
| 470 | + clearInterval(bufferInterval) | ||
| 471 | + } | ||
| 472 | + }, 2000) | ||
| 473 | + | ||
| 474 | + // 播放器销毁时清除定时器 | ||
| 475 | + const originalDispose = player.value.dispose | ||
| 476 | + player.value.dispose = function () { | ||
| 477 | + clearInterval(bufferInterval) | ||
| 478 | + return originalDispose.call(this) | ||
| 479 | + } | ||
| 480 | + | ||
| 481 | + // 立即检查一次 | ||
| 482 | + setTimeout(checkBuffer, 100) | ||
| 483 | + | ||
| 484 | + // 使用 Performance API 监控网络请求 | ||
| 485 | + setTimeout(() => { | ||
| 486 | + try { | ||
| 487 | + const perfEntries = performance.getEntriesByType('resource') | ||
| 488 | + const videoRequests = perfEntries.filter(entry => { | ||
| 489 | + const url = entry.name?.toLowerCase() || '' | ||
| 490 | + return url.includes('.mp4') || url.includes('.m3u8') || url.includes('cdn') | ||
| 491 | + }) | ||
| 492 | + | ||
| 493 | + if (videoRequests.length > 0) { | ||
| 494 | + console.group('🌐 [VideoPlayer] 网络请求监控') | ||
| 495 | + videoRequests.forEach((req, index) => { | ||
| 496 | + console.log(`请求 #${index + 1}:`) | ||
| 497 | + console.log(' URL:', req.name) | ||
| 498 | + console.log( | ||
| 499 | + ' 状态:', | ||
| 500 | + 'transferSize' in req | ||
| 501 | + ? req.transferSize > 0 | ||
| 502 | + ? '✅ 成功' | ||
| 503 | + : '⚠️ 可能为空' | ||
| 504 | + : '未知' | ||
| 505 | + ) | ||
| 506 | + console.log(' 传输大小:', (req.transferSize / 1024 / 1024).toFixed(2), 'MB') | ||
| 507 | + console.log(' 编码大小:', (req.encodedBodySize / 1024 / 1024).toFixed(2), 'MB') | ||
| 508 | + console.log(' 解码大小:', (req.decodedBodySize / 1024 / 1024).toFixed(2), 'MB') | ||
| 509 | + console.log(' 持续时间:', (req.duration / 1000).toFixed(2), '秒') | ||
| 510 | + console.log( | ||
| 511 | + ' 是否完整:', | ||
| 512 | + req.transferSize === req.encodedBodySize ? '是' : '否(可能中断)' | ||
| 513 | + ) | ||
| 514 | + | ||
| 515 | + // 检查是否被中断 | ||
| 516 | + if (req.transferSize > 0 && req.transferSize < req.encodedBodySize) { | ||
| 517 | + console.error('❌ 请求可能被中断!') | ||
| 518 | + } | ||
| 519 | + }) | ||
| 520 | + console.groupEnd() | ||
| 521 | + } | ||
| 522 | + } catch (e) { | ||
| 523 | + console.log('🌐 [VideoPlayer] Performance API 不可用') | ||
| 524 | + } | ||
| 525 | + }, 3000) // 3秒后检查请求状态 | ||
| 526 | + } | ||
| 527 | + | ||
| 331 | const quality_selector_inited = { value: false } | 528 | const quality_selector_inited = { value: false } |
| 332 | const setupQualitySelector = () => { | 529 | const setupQualitySelector = () => { |
| 333 | if (quality_selector_inited.value) return | 530 | if (quality_selector_inited.value) return |
| ... | @@ -424,7 +621,23 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -424,7 +621,23 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 424 | }) | 621 | }) |
| 425 | 622 | ||
| 426 | if (props.autoplay) { | 623 | if (props.autoplay) { |
| 427 | - player.value.play().catch(() => {}) | 624 | + if (props.debug) { |
| 625 | + console.log('▶️ [VideoPlayer] 尝试自动播放') | ||
| 626 | + } | ||
| 627 | + player.value | ||
| 628 | + .play() | ||
| 629 | + .then(() => { | ||
| 630 | + if (props.debug) { | ||
| 631 | + console.log('✅ [VideoPlayer] 自动播放成功') | ||
| 632 | + } | ||
| 633 | + }) | ||
| 634 | + .catch(err => { | ||
| 635 | + if (props.debug) { | ||
| 636 | + console.error('❌ [VideoPlayer] 自动播放失败:', err) | ||
| 637 | + console.error(' 失败原因:', err.name) | ||
| 638 | + console.error(' 错误消息:', err.message) | ||
| 639 | + } | ||
| 640 | + }) | ||
| 428 | } | 641 | } |
| 429 | } | 642 | } |
| 430 | } | 643 | } |
| ... | @@ -528,6 +741,15 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -528,6 +741,15 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 528 | ) | 741 | ) |
| 529 | 742 | ||
| 530 | onMounted(() => { | 743 | onMounted(() => { |
| 744 | + // 调试日志:组件挂载 | ||
| 745 | + if (props.debug) { | ||
| 746 | + console.group('🎬 [VideoPlayer] 组件挂载') | ||
| 747 | + console.log('📹 视频 URL:', videoUrlValue.value) | ||
| 748 | + console.log('🎥 播放器类型:', useNativePlayer.value ? '原生播放器' : 'Video.js') | ||
| 749 | + console.log('🌐 是否 HLS:', isM3U8.value) | ||
| 750 | + console.groupEnd() | ||
| 751 | + } | ||
| 752 | + | ||
| 531 | void probeVideo() | 753 | void probeVideo() |
| 532 | if (useNativePlayer.value) { | 754 | if (useNativePlayer.value) { |
| 533 | initNativePlayer() | 755 | initNativePlayer() |
| ... | @@ -535,6 +757,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -535,6 +757,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 535 | }) | 757 | }) |
| 536 | 758 | ||
| 537 | onBeforeUnmount(() => { | 759 | onBeforeUnmount(() => { |
| 760 | + // 调试日志:组件卸载 | ||
| 761 | + if (props.debug) { | ||
| 762 | + console.log('🗑️ [VideoPlayer] 组件卸载,清理播放器资源') | ||
| 763 | + } | ||
| 764 | + | ||
| 538 | if (retry_error_check_timer) { | 765 | if (retry_error_check_timer) { |
| 539 | clearTimeout(retry_error_check_timer) | 766 | clearTimeout(retry_error_check_timer) |
| 540 | retry_error_check_timer = null | 767 | retry_error_check_timer = null | ... | ... |
| ... | @@ -368,6 +368,7 @@ | ... | @@ -368,6 +368,7 @@ |
| 368 | </template> | 368 | </template> |
| 369 | <VideoPlayer | 369 | <VideoPlayer |
| 370 | v-else | 370 | v-else |
| 371 | + :key="`video_${index}_${item.video_url}`" | ||
| 371 | :video-url="item.video_url" | 372 | :video-url="item.video_url" |
| 372 | :video-id="`home_recommend_video_${index}`" | 373 | :video-id="`home_recommend_video_${index}`" |
| 373 | :use-native-on-ios="false" | 374 | :use-native-on-ios="false" |
| ... | @@ -394,6 +395,16 @@ import LiveStreamCard from '@/components/courses/LiveStreamCard.vue' | ... | @@ -394,6 +395,16 @@ import LiveStreamCard from '@/components/courses/LiveStreamCard.vue' |
| 394 | import VideoPlayer from '@/components/media/VideoPlayer.vue' | 395 | import VideoPlayer from '@/components/media/VideoPlayer.vue' |
| 395 | import CheckInList from '@/components/checkin/CheckInList.vue' | 396 | import CheckInList from '@/components/checkin/CheckInList.vue' |
| 396 | 397 | ||
| 398 | +// Video.js 资源预加载(解决首次播放黑屏问题) | ||
| 399 | +const preloadVideoJs = () => { | ||
| 400 | + // 在用户可能点击视频前,预先加载 Video.js 资源 | ||
| 401 | + import('video.js/dist/video-js.css') | ||
| 402 | + import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css') | ||
| 403 | + import('videojs-contrib-quality-levels') | ||
| 404 | + import('videojs-hls-quality-selector') | ||
| 405 | + import('@videojs-player/vue') | ||
| 406 | +} | ||
| 407 | + | ||
| 397 | import FeaturedCoursesSection from '@/components/homePage/FeaturedCoursesSection.vue' | 408 | import FeaturedCoursesSection from '@/components/homePage/FeaturedCoursesSection.vue' |
| 398 | import RecommendationsSection from '@/components/homePage/RecommendationsSection.vue' | 409 | import RecommendationsSection from '@/components/homePage/RecommendationsSection.vue' |
| 399 | import LatestActivitiesSection from '@/components/homePage/LatestActivitiesSection.vue' | 410 | import LatestActivitiesSection from '@/components/homePage/LatestActivitiesSection.vue' |
| ... | @@ -431,6 +442,9 @@ const activeTab = ref('推荐') // 当前激活的内容标签页 | ... | @@ -431,6 +442,9 @@ const activeTab = ref('推荐') // 当前激活的内容标签页 |
| 431 | const checkInTypes = ref([]) | 442 | const checkInTypes = ref([]) |
| 432 | 443 | ||
| 433 | onMounted(() => { | 444 | onMounted(() => { |
| 445 | + // 预加载 Video.js 资源,避免首次播放时黑屏 | ||
| 446 | + preloadVideoJs() | ||
| 447 | + | ||
| 434 | watch( | 448 | watch( |
| 435 | () => currentUser.value, | 449 | () => currentUser.value, |
| 436 | async newVal => { | 450 | async newVal => { | ... | ... |
| ... | @@ -3,44 +3,76 @@ | ... | @@ -3,44 +3,76 @@ |
| 3 | * @Description: 学习详情页面 | 3 | * @Description: 学习详情页面 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | - <div class="study-detail-page bg-gradient-to-b from-green-50/70 to-white/90 min-h-screen pb-20"> | 6 | + <div class="study-detail-page min-h-screen bg-gradient-to-b from-green-50/70 to-white/90 pb-20"> |
| 7 | - <div v-if="course" class="flex flex-col h-screen"> | 7 | + <div v-if="course" class="flex h-screen flex-col"> |
| 8 | <!-- 固定区域:视频播放和标签页 --> | 8 | <!-- 固定区域:视频播放和标签页 --> |
| 9 | - <div class="fixed top-0 left-0 right-0 z-10 top-wrapper"> | 9 | + <div class="top-wrapper fixed left-0 right-0 top-0 z-10"> |
| 10 | <!-- 视频播放区域 --> | 10 | <!-- 视频播放区域 --> |
| 11 | - <div v-if="course.course_type === 'video'" class="w-full relative"> | 11 | + <div v-if="course.course_type === 'video'" class="relative w-full"> |
| 12 | <!-- 视频封面和播放按钮 --> | 12 | <!-- 视频封面和播放按钮 --> |
| 13 | - <div v-if="!isPlaying" class="relative w-full" :style="{ aspectRatio: '16/9', backgroundColor: courseFile.cover ? 'transparent' : '#000' }"> | ||
| 14 | - <img v-if="courseFile.cover" :src="courseFile.cover" :alt="course.title" class="w-full h-full object-cover" /> | ||
| 15 | - <div class="absolute inset-0 flex items-center justify-center cursor-pointer" | ||
| 16 | - @click="startPlay"> | ||
| 17 | <div | 13 | <div |
| 18 | - class="w-20 h-20 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors"> | 14 | + v-if="!isPlaying" |
| 19 | - <font-awesome-icon icon="circle-play" class="text-5xl text-white" | 15 | + class="relative w-full" |
| 20 | - style="font-size: 3rem;" /> | 16 | + :style="{ |
| 17 | + aspectRatio: '16/9', | ||
| 18 | + backgroundColor: courseFile.cover ? 'transparent' : '#000', | ||
| 19 | + }" | ||
| 20 | + > | ||
| 21 | + <img | ||
| 22 | + v-if="courseFile.cover" | ||
| 23 | + :src="courseFile.cover" | ||
| 24 | + :alt="course.title" | ||
| 25 | + class="h-full w-full object-cover" | ||
| 26 | + /> | ||
| 27 | + <div | ||
| 28 | + class="absolute inset-0 flex cursor-pointer items-center justify-center" | ||
| 29 | + @click="startPlay" | ||
| 30 | + > | ||
| 31 | + <div | ||
| 32 | + class="flex h-20 w-20 items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70" | ||
| 33 | + > | ||
| 34 | + <font-awesome-icon | ||
| 35 | + icon="circle-play" | ||
| 36 | + class="text-5xl text-white" | ||
| 37 | + style="font-size: 3rem" | ||
| 38 | + /> | ||
| 21 | </div> | 39 | </div> |
| 22 | </div> | 40 | </div> |
| 23 | </div> | 41 | </div> |
| 24 | <!-- 视频播放器 --> | 42 | <!-- 视频播放器 --> |
| 25 | - <VideoPlayer v-if="isPlaying" ref="videoPlayerRef" | 43 | + <VideoPlayer |
| 26 | - :video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''" :autoplay="false" | 44 | + v-if="isPlaying" |
| 45 | + ref="videoPlayerRef" | ||
| 46 | + :video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''" | ||
| 47 | + :autoplay="false" | ||
| 27 | :video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''" | 48 | :video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''" |
| 28 | :use-native-on-ios="false" | 49 | :use-native-on-ios="false" |
| 29 | - @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> | 50 | + :debug="false" |
| 51 | + @onPlay="handleVideoPlay" | ||
| 52 | + @onPause="handleVideoPause" | ||
| 53 | + /> | ||
| 30 | </div> | 54 | </div> |
| 31 | - <div v-if="course.course_type === 'audio'" class="w-full relative border-b border-gray-200"> | 55 | + <div v-if="course.course_type === 'audio'" class="relative w-full border-b border-gray-200"> |
| 32 | <!-- 音频播放器 --> | 56 | <!-- 音频播放器 --> |
| 33 | - <AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay" | 57 | + <AudioPlayer |
| 34 | - @pause="onAudioPause" /> | 58 | + ref="audioPlayerRef" |
| 59 | + v-if="audioList.length" | ||
| 60 | + :songs="audioList" | ||
| 61 | + @play="onAudioPlay" | ||
| 62 | + @pause="onAudioPause" | ||
| 63 | + /> | ||
| 35 | </div> | 64 | </div> |
| 36 | <!-- 图片列表展示区域 --> | 65 | <!-- 图片列表展示区域 --> |
| 37 | - <div v-if="course.course_type === 'image'" class="w-full relative"> | 66 | + <div v-if="course.course_type === 'image'" class="relative w-full"> |
| 38 | <van-swipe class="w-full" :autoplay="0" :show-indicators="true"> | 67 | <van-swipe class="w-full" :autoplay="0" :show-indicators="true"> |
| 39 | - <van-swipe-item v-for="(item, index) in courseFile?.list" :key="index" | 68 | + <van-swipe-item |
| 40 | - @click.native="showImagePreview(index)"> | 69 | + v-for="(item, index) in courseFile?.list" |
| 41 | - <van-image :src="item.url" class="w-full" fit="cover" style="aspect-ratio: 16/9;"> | 70 | + :key="index" |
| 71 | + @click.native="showImagePreview(index)" | ||
| 72 | + > | ||
| 73 | + <van-image :src="item.url" class="w-full" fit="cover" style="aspect-ratio: 16/9"> | ||
| 42 | <template #image> | 74 | <template #image> |
| 43 | - <img :src="item.url" class="w-full h-full object-cover" /> | 75 | + <img :src="item.url" class="h-full w-full object-cover" /> |
| 44 | </template> | 76 | </template> |
| 45 | </van-image> | 77 | </van-image> |
| 46 | </van-swipe-item> | 78 | </van-swipe-item> |
| ... | @@ -68,47 +100,66 @@ | ... | @@ -68,47 +100,66 @@ |
| 68 | </div> --> | 100 | </div> --> |
| 69 | 101 | ||
| 70 | <!-- 默认展示区 --> | 102 | <!-- 默认展示区 --> |
| 71 | - <div v-if="!course.course_type" class="relative" style="border-bottom: 1px solid #e5e7eb;"> | 103 | + <div v-if="!course.course_type" class="relative" style="border-bottom: 1px solid #e5e7eb"> |
| 72 | - <div class="h-24 bg-white flex items-center justify-center px-4"> | 104 | + <div class="flex h-24 items-center justify-center bg-white px-4"> |
| 73 | - <h3 class="text-lg font-medium text-gray-900 truncate">{{ course.title }}</h3> | 105 | + <h3 class="truncate text-lg font-medium text-gray-900">{{ course.title }}</h3> |
| 74 | </div> | 106 | </div> |
| 75 | </div> | 107 | </div> |
| 76 | <!-- 标签页区域 --> | 108 | <!-- 标签页区域 --> |
| 77 | - <div class="px-4 py-3 bg-white" style="position: relative;"> | 109 | + <div class="bg-white px-4 py-3" style="position: relative"> |
| 78 | - <van-tabs v-model:active="activeTab" sticky animated swipeable shrink color="#4caf50" | 110 | + <van-tabs |
| 79 | - @change="handleTabChange"> | 111 | + v-model:active="activeTab" |
| 80 | - <van-tab title="介绍" name="intro"> | 112 | + sticky |
| 81 | - </van-tab> | 113 | + animated |
| 114 | + swipeable | ||
| 115 | + shrink | ||
| 116 | + color="#4caf50" | ||
| 117 | + @change="handleTabChange" | ||
| 118 | + > | ||
| 119 | + <van-tab title="介绍" name="intro"> </van-tab> | ||
| 82 | <van-tab :title-style="{ 'min-width': '50%' }" name="comments"> | 120 | <van-tab :title-style="{ 'min-width': '50%' }" name="comments"> |
| 83 | <template #title>评论({{ commentCount }})</template> | 121 | <template #title>评论({{ commentCount }})</template> |
| 84 | </van-tab> | 122 | </van-tab> |
| 85 | </van-tabs> | 123 | </van-tabs> |
| 86 | - <div v-if="task_list.length > 0" @click="goToCheckin" | 124 | + <div |
| 87 | - style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">打卡互动 | 125 | + v-if="task_list.length > 0" |
| 126 | + @click="goToCheckin" | ||
| 127 | + style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666" | ||
| 128 | + > | ||
| 129 | + 打卡互动 | ||
| 88 | </div> | 130 | </div> |
| 89 | </div> | 131 | </div> |
| 90 | </div> | 132 | </div> |
| 91 | 133 | ||
| 92 | <!-- 滚动区域:介绍和评论内容 --> | 134 | <!-- 滚动区域:介绍和评论内容 --> |
| 93 | - <div class="overflow-y-auto flex-1" | 135 | + <div |
| 94 | - :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }"> | 136 | + class="flex-1 overflow-y-auto" |
| 95 | - <div id="intro" class="py-4 px-4"> | 137 | + :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }" |
| 96 | - <h1 class="text-lg font-bold mb-2">{{ course.title }}</h1> | 138 | + > |
| 97 | - <div class="text-gray-500 text-sm flex items-center gap-2"> | 139 | + <div id="intro" class="px-4 py-4"> |
| 98 | - <span>开课时间 {{ course.schedule_time ? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss') : | 140 | + <h1 class="mb-2 text-lg font-bold">{{ course.title }}</h1> |
| 99 | - '暂无' | 141 | + <div class="flex items-center gap-2 text-sm text-gray-500"> |
| 100 | - }}</span> | 142 | + <span |
| 143 | + >开课时间 | ||
| 144 | + {{ | ||
| 145 | + course.schedule_time | ||
| 146 | + ? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss') | ||
| 147 | + : '暂无' | ||
| 148 | + }}</span | ||
| 149 | + > | ||
| 101 | <!-- <span class="text-gray-300">|</span> --> | 150 | <!-- <span class="text-gray-300">|</span> --> |
| 102 | <!-- <span>没有字段{{ course.studyCount || 0 }}次学习</span> --> | 151 | <!-- <span>没有字段{{ course.studyCount || 0 }}次学习</span> --> |
| 103 | </div> | 152 | </div> |
| 104 | 153 | ||
| 105 | <!-- 学习资料入口 --> | 154 | <!-- 学习资料入口 --> |
| 106 | - <div v-if="course.course_type === 'file' && courseFile?.list && courseFile.list.length > 0" | 155 | + <div |
| 107 | - class="bg-white rounded-lg p-4 mb-4 cursor-pointer hover:bg-gray-50 transition-colors mt-4 shadow-sm" | 156 | + v-if="course.course_type === 'file' && courseFile?.list && courseFile.list.length > 0" |
| 108 | - @click="showMaterialsPopup = true"> | 157 | + class="mb-4 mt-4 cursor-pointer rounded-lg bg-white p-4 shadow-sm transition-colors hover:bg-gray-50" |
| 158 | + @click="showMaterialsPopup = true" | ||
| 159 | + > | ||
| 109 | <div class="flex items-center justify-between"> | 160 | <div class="flex items-center justify-between"> |
| 110 | <div class="flex items-center gap-3"> | 161 | <div class="flex items-center gap-3"> |
| 111 | - <div class="w-10 h-10 bg-green-600 rounded-lg flex items-center justify-center"> | 162 | + <div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-600"> |
| 112 | <van-icon name="notes" class="text-white" size="20" /> | 163 | <van-icon name="notes" class="text-white" size="20" /> |
| 113 | </div> | 164 | </div> |
| 114 | <div> | 165 | <div> |
| ... | @@ -123,23 +174,34 @@ | ... | @@ -123,23 +174,34 @@ |
| 123 | 174 | ||
| 124 | <div class="h-2 bg-gray-100"></div> | 175 | <div class="h-2 bg-gray-100"></div> |
| 125 | <!-- 评论区 --> | 176 | <!-- 评论区 --> |
| 126 | - <StudyCommentsSection :comment-count="commentCount" :comment-list="commentList" | 177 | + <StudyCommentsSection |
| 127 | - :popup-comment-list="popupCommentList" :popup-finished="popupFinished" | 178 | + :comment-count="commentCount" |
| 128 | - :bottom-wrapper-height="bottomWrapperHeight" v-model:showCommentPopup="showCommentPopup" | 179 | + :comment-list="commentList" |
| 129 | - v-model:popupLoading="popupLoading" v-model:popupComment="popupComment" @toggleLike="toggleLike" | 180 | + :popup-comment-list="popupCommentList" |
| 130 | - @popupLoad="onPopupLoad" @submitPopupComment="submitPopupComment" | 181 | + :popup-finished="popupFinished" |
| 131 | - @commentDeleted="handleCommentDeleted" /> | 182 | + :bottom-wrapper-height="bottomWrapperHeight" |
| 183 | + v-model:showCommentPopup="showCommentPopup" | ||
| 184 | + v-model:popupLoading="popupLoading" | ||
| 185 | + v-model:popupComment="popupComment" | ||
| 186 | + @toggleLike="toggleLike" | ||
| 187 | + @popupLoad="onPopupLoad" | ||
| 188 | + @submitPopupComment="submitPopupComment" | ||
| 189 | + @commentDeleted="handleCommentDeleted" | ||
| 190 | + /> | ||
| 132 | </div> | 191 | </div> |
| 133 | 192 | ||
| 134 | <!-- 底部操作栏 --> | 193 | <!-- 底部操作栏 --> |
| 135 | <div | 194 | <div |
| 136 | - class="fixed bottom-0 left-0 right-0 bg-white border-t px-4 py-2 flex items-center space-x-4 bottom-wrapper"> | 195 | + class="bottom-wrapper fixed bottom-0 left-0 right-0 flex items-center space-x-4 border-t bg-white px-4 py-2" |
| 137 | - <div class="flex-none flex flex-col items-center gap-1 cursor-pointer active:opacity-80" | 196 | + > |
| 138 | - @click="showCatalog = true"> | 197 | + <div |
| 198 | + class="flex flex-none cursor-pointer flex-col items-center gap-1 active:opacity-80" | ||
| 199 | + @click="showCatalog = true" | ||
| 200 | + > | ||
| 139 | <van-icon name="bars" class="text-lg text-gray-600" /> | 201 | <van-icon name="bars" class="text-lg text-gray-600" /> |
| 140 | <span class="text-xs text-gray-600">课程目录</span> | 202 | <span class="text-xs text-gray-600">课程目录</span> |
| 141 | </div> | 203 | </div> |
| 142 | - <div class="flex-grow flex-1 min-w-0"> | 204 | + <div class="min-w-0 flex-1 flex-grow"> |
| 143 | <!-- <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言" | 205 | <!-- <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言" |
| 144 | class="bg-gray-100 rounded-lg !p-0"> | 206 | class="bg-gray-100 rounded-lg !p-0"> |
| 145 | <template #input> | 207 | <template #input> |
| ... | @@ -147,16 +209,26 @@ | ... | @@ -147,16 +209,26 @@ |
| 147 | class="w-full h-full bg-transparent outline-none resize-none" /> | 209 | class="w-full h-full bg-transparent outline-none resize-none" /> |
| 148 | </template> | 210 | </template> |
| 149 | </van-field> --> | 211 | </van-field> --> |
| 150 | - <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入评论" | 212 | + <van-field |
| 151 | - class="flex-1 bg-gray-100 rounded-lg" /> | 213 | + v-model="newComment" |
| 214 | + rows="1" | ||
| 215 | + autosize | ||
| 216 | + type="textarea" | ||
| 217 | + placeholder="请输入评论" | ||
| 218 | + class="flex-1 rounded-lg bg-gray-100" | ||
| 219 | + /> | ||
| 152 | </div> | 220 | </div> |
| 153 | <van-button type="primary" size="small" @click="submitComment">发送</van-button> | 221 | <van-button type="primary" size="small" @click="submitComment">发送</van-button> |
| 154 | </div> | 222 | </div> |
| 155 | </div> | 223 | </div> |
| 156 | 224 | ||
| 157 | <!-- 图片预览组件 --> | 225 | <!-- 图片预览组件 --> |
| 158 | - <van-image-preview v-model:show="showPreview" :images="previewImages" :show-index="false" | 226 | + <van-image-preview |
| 159 | - :close-on-click-image="false"> | 227 | + v-model:show="showPreview" |
| 228 | + :images="previewImages" | ||
| 229 | + :show-index="false" | ||
| 230 | + :close-on-click-image="false" | ||
| 231 | + > | ||
| 160 | <template #image="{ src, style, onLoad }"> | 232 | <template #image="{ src, style, onLoad }"> |
| 161 | <img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" /> | 233 | <img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" /> |
| 162 | </template> | 234 | </template> |
| ... | @@ -169,8 +241,13 @@ | ... | @@ -169,8 +241,13 @@ |
| 169 | </van-image-preview> | 241 | </van-image-preview> |
| 170 | 242 | ||
| 171 | <!-- 课程目录弹窗 --> | 243 | <!-- 课程目录弹窗 --> |
| 172 | - <StudyCatalogPopup v-model:showCatalog="showCatalog" :lessons="course_lessons" :course-id="courseId" | 244 | + <StudyCatalogPopup |
| 173 | - :course-type-maps="course_type_maps" @lessonClick="handleLessonClick" /> | 245 | + v-model:showCatalog="showCatalog" |
| 246 | + :lessons="course_lessons" | ||
| 247 | + :course-id="courseId" | ||
| 248 | + :course-type-maps="course_type_maps" | ||
| 249 | + @lessonClick="handleLessonClick" | ||
| 250 | + /> | ||
| 174 | 251 | ||
| 175 | <!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview --> | 252 | <!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview --> |
| 176 | 253 | ||
| ... | @@ -195,71 +272,109 @@ | ... | @@ -195,71 +272,109 @@ |
| 195 | </van-popup>--> | 272 | </van-popup>--> |
| 196 | 273 | ||
| 197 | <!-- 音频播放器弹窗 --> | 274 | <!-- 音频播放器弹窗 --> |
| 198 | - <van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }"> | 275 | + <van-popup |
| 276 | + v-model:show="audioShow" | ||
| 277 | + position="bottom" | ||
| 278 | + round | ||
| 279 | + closeable | ||
| 280 | + :style="{ height: '60%', width: '100%' }" | ||
| 281 | + > | ||
| 199 | <div class="p-4"> | 282 | <div class="p-4"> |
| 200 | - <h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3> | 283 | + <h3 class="mb-4 text-center text-lg font-medium">{{ audioTitle }}</h3> |
| 201 | - <AudioPlayer v-if="audioShow && audioUrl" :songs="[{ title: audioTitle, url: audioUrl }]" | 284 | + <AudioPlayer |
| 202 | - class="w-full" /> | 285 | + v-if="audioShow && audioUrl" |
| 286 | + :songs="[{ title: audioTitle, url: audioUrl }]" | ||
| 287 | + class="w-full" | ||
| 288 | + /> | ||
| 203 | </div> | 289 | </div> |
| 204 | </van-popup> | 290 | </van-popup> |
| 205 | 291 | ||
| 206 | <!-- 视频播放器弹窗 --> | 292 | <!-- 视频播放器弹窗 --> |
| 207 | - <van-popup v-model:show="videoShow" position="center" round closeable | 293 | + <van-popup |
| 208 | - :style="{ width: '95%', maxHeight: '80vh' }" @close="stopPopupVideoPlay"> | 294 | + v-model:show="videoShow" |
| 295 | + position="center" | ||
| 296 | + round | ||
| 297 | + closeable | ||
| 298 | + :style="{ width: '95%', maxHeight: '80vh' }" | ||
| 299 | + @close="stopPopupVideoPlay" | ||
| 300 | + > | ||
| 209 | <div class="p-4"> | 301 | <div class="p-4"> |
| 210 | - <h3 class="text-lg font-medium mb-4 text-center">视频预览</h3> | 302 | + <h3 class="mb-4 text-center text-lg font-medium">视频预览</h3> |
| 211 | - <div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;"> | 303 | + <div class="relative w-full overflow-hidden rounded-lg bg-black" style="aspect-ratio: 16/9"> |
| 212 | <!-- 视频封面 --> | 304 | <!-- 视频封面 --> |
| 213 | - <div v-show="!isPopupVideoPlaying" | 305 | + <div |
| 214 | - class="absolute inset-0 bg-black flex items-center justify-center cursor-pointer" | 306 | + v-show="!isPopupVideoPlaying" |
| 215 | - @click="startPopupVideoPlay"> | 307 | + class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black" |
| 216 | - <div class="w-16 h-16 bg-white bg-opacity-80 rounded-full flex items-center justify-center"> | 308 | + @click="startPopupVideoPlay" |
| 217 | - <svg class="w-8 h-8 text-black ml-1" fill="currentColor" viewBox="0 0 24 24"> | 309 | + > |
| 310 | + <div | ||
| 311 | + class="flex h-16 w-16 items-center justify-center rounded-full bg-white bg-opacity-80" | ||
| 312 | + > | ||
| 313 | + <svg class="ml-1 h-8 w-8 text-black" fill="currentColor" viewBox="0 0 24 24"> | ||
| 218 | <path d="M8 5v14l11-7z" /> | 314 | <path d="M8 5v14l11-7z" /> |
| 219 | </svg> | 315 | </svg> |
| 220 | </div> | 316 | </div> |
| 221 | </div> | 317 | </div> |
| 222 | <!-- 视频播放器 --> | 318 | <!-- 视频播放器 --> |
| 223 | - <VideoPlayer v-show="isPopupVideoPlaying" ref="popupVideoPlayerRef" :video-url="videoUrl" | 319 | + <VideoPlayer |
| 224 | - :video-id="videoTitle" :autoplay="false" :use-native-on-ios="false" class="w-full h-full" @play="handlePopupVideoPlay" | 320 | + v-show="isPopupVideoPlaying" |
| 225 | - @pause="handlePopupVideoPause" /> | 321 | + ref="popupVideoPlayerRef" |
| 322 | + :video-url="videoUrl" | ||
| 323 | + :video-id="videoTitle" | ||
| 324 | + :autoplay="false" | ||
| 325 | + :use-native-on-ios="false" | ||
| 326 | + :debug="true" | ||
| 327 | + class="h-full w-full" | ||
| 328 | + @play="handlePopupVideoPlay" | ||
| 329 | + @pause="handlePopupVideoPause" | ||
| 330 | + /> | ||
| 226 | </div> | 331 | </div> |
| 227 | </div> | 332 | </div> |
| 228 | </van-popup> | 333 | </van-popup> |
| 229 | 334 | ||
| 230 | <!-- 打卡弹窗 --> | 335 | <!-- 打卡弹窗 --> |
| 231 | - <CheckInDialog v-model:show="showCheckInDialog" :items_today="task_list" :items_history="timeout_task_list" | 336 | + <CheckInDialog |
| 232 | - @check-in-success="handleCheckInSuccess" /> | 337 | + v-model:show="showCheckInDialog" |
| 338 | + :items_today="task_list" | ||
| 339 | + :items_history="timeout_task_list" | ||
| 340 | + @check-in-success="handleCheckInSuccess" | ||
| 341 | + /> | ||
| 233 | 342 | ||
| 234 | <!-- 学习资源弹窗 --> | 343 | <!-- 学习资源弹窗 --> |
| 235 | - <StudyMaterialsPopup v-model:showMaterialsPopup="showMaterialsPopup" :files="courseFile?.list || []" | 344 | + <StudyMaterialsPopup |
| 236 | - @openPdf="showPdf" @openAudio="showAudio" @openVideo="showVideo" @openImage="showImage" /> | 345 | + v-model:showMaterialsPopup="showMaterialsPopup" |
| 346 | + :files="courseFile?.list || []" | ||
| 347 | + @openPdf="showPdf" | ||
| 348 | + @openAudio="showAudio" | ||
| 349 | + @openVideo="showVideo" | ||
| 350 | + @openImage="showImage" | ||
| 351 | + /> | ||
| 237 | </div> | 352 | </div> |
| 238 | </template> | 353 | </template> |
| 239 | 354 | ||
| 240 | <script setup> | 355 | <script setup> |
| 241 | -import { ref, onMounted, nextTick, computed, watch } from 'vue'; | 356 | +import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue' |
| 242 | -import { useRoute, useRouter } from 'vue-router'; | 357 | +import { useRoute, useRouter } from 'vue-router' |
| 243 | -import { useTitle } from '@vueuse/core'; | 358 | +import { useTitle } from '@vueuse/core' |
| 244 | -import VideoPlayer from '@/components/media/VideoPlayer.vue'; | 359 | +import VideoPlayer from '@/components/media/VideoPlayer.vue' |
| 245 | -import AudioPlayer from '@/components/media/AudioPlayer.vue'; | 360 | +import AudioPlayer from '@/components/media/AudioPlayer.vue' |
| 246 | -import CheckInDialog from '@/components/checkin/CheckInDialog.vue'; | 361 | +import CheckInDialog from '@/components/checkin/CheckInDialog.vue' |
| 247 | // import OfficeViewer from '@/components/media/OfficeViewer.vue'; | 362 | // import OfficeViewer from '@/components/media/OfficeViewer.vue'; |
| 248 | -import dayjs from 'dayjs'; | 363 | +import dayjs from 'dayjs' |
| 249 | -import { showToast } from 'vant'; | 364 | +import { showToast } from 'vant' |
| 250 | import { normalizeCheckinTaskItems } from '@/utils/tools' | 365 | import { normalizeCheckinTaskItems } from '@/utils/tools' |
| 251 | -import StudyCommentsSection from '@/components/studyDetail/StudyCommentsSection.vue'; | 366 | +import StudyCommentsSection from '@/components/studyDetail/StudyCommentsSection.vue' |
| 252 | -import StudyCatalogPopup from '@/components/studyDetail/StudyCatalogPopup.vue'; | 367 | +import StudyCatalogPopup from '@/components/studyDetail/StudyCatalogPopup.vue' |
| 253 | -import StudyMaterialsPopup from '@/components/studyDetail/StudyMaterialsPopup.vue'; | 368 | +import StudyMaterialsPopup from '@/components/studyDetail/StudyMaterialsPopup.vue' |
| 254 | -import { useStudyComments } from '@/composables/useStudyComments'; | 369 | +import { useStudyComments } from '@/composables/useStudyComments' |
| 255 | -import { useStudyRecordTracker } from '@/composables/useStudyRecordTracker'; | 370 | +import { useStudyRecordTracker } from '@/composables/useStudyRecordTracker' |
| 256 | 371 | ||
| 257 | // 导入接口 | 372 | // 导入接口 |
| 258 | -import { getScheduleCourseAPI, getCourseDetailAPI } from '@/api/course'; | 373 | +import { getScheduleCourseAPI, getCourseDetailAPI } from '@/api/course' |
| 259 | 374 | ||
| 260 | -const route = useRoute(); | 375 | +const route = useRoute() |
| 261 | -const router = useRouter(); | 376 | +const router = useRouter() |
| 262 | -const course = ref(null); | 377 | +const course = ref(null) |
| 263 | 378 | ||
| 264 | const { | 379 | const { |
| 265 | commentCount, | 380 | commentCount, |
| ... | @@ -274,8 +389,8 @@ const { | ... | @@ -274,8 +389,8 @@ const { |
| 274 | toggleLike, | 389 | toggleLike, |
| 275 | submitComment, | 390 | submitComment, |
| 276 | onPopupLoad, | 391 | onPopupLoad, |
| 277 | - submitPopupComment | 392 | + submitPopupComment, |
| 278 | -} = useStudyComments(course); | 393 | +} = useStudyComments(course) |
| 279 | 394 | ||
| 280 | /** | 395 | /** |
| 281 | * @function handleCommentDeleted | 396 | * @function handleCommentDeleted |
| ... | @@ -283,7 +398,7 @@ const { | ... | @@ -283,7 +398,7 @@ const { |
| 283 | * @param {number|string} comment_id - 被删除的评论ID | 398 | * @param {number|string} comment_id - 被删除的评论ID |
| 284 | * @returns {void} | 399 | * @returns {void} |
| 285 | */ | 400 | */ |
| 286 | -const handleCommentDeleted = (comment_id) => { | 401 | +const handleCommentDeleted = comment_id => { |
| 287 | const id = String(comment_id || '') | 402 | const id = String(comment_id || '') |
| 288 | if (!id) return | 403 | if (!id) return |
| 289 | 404 | ||
| ... | @@ -292,97 +407,117 @@ const handleCommentDeleted = (comment_id) => { | ... | @@ -292,97 +407,117 @@ const handleCommentDeleted = (comment_id) => { |
| 292 | commentCount.value = Math.max(0, Number(commentCount.value || 0) - 1) | 407 | commentCount.value = Math.max(0, Number(commentCount.value || 0) - 1) |
| 293 | } | 408 | } |
| 294 | 409 | ||
| 295 | -const activeTab = ref('intro'); | 410 | +const activeTab = ref('intro') |
| 296 | -const showCatalog = ref(false); | 411 | +const showCatalog = ref(false) |
| 297 | -const isPlaying = ref(false); | 412 | +const isPlaying = ref(false) |
| 298 | -const videoPlayerRef = ref(null); | 413 | +const videoPlayerRef = ref(null) |
| 299 | -const audioPlayerRef = ref(null); | 414 | +const audioPlayerRef = ref(null) |
| 300 | 415 | ||
| 301 | // 课程目录相关 | 416 | // 课程目录相关 |
| 302 | -const course_lessons = ref([]); | 417 | +const course_lessons = ref([]) |
| 303 | const course_type_maps = ref({ | 418 | const course_type_maps = ref({ |
| 304 | video: '视频', | 419 | video: '视频', |
| 305 | audio: '音频', | 420 | audio: '音频', |
| 306 | image: '图片', | 421 | image: '图片', |
| 307 | file: '文件', | 422 | file: '文件', |
| 308 | -}); | 423 | +}) |
| 309 | 424 | ||
| 310 | // 开始播放视频 | 425 | // 开始播放视频 |
| 311 | const startPlay = async () => { | 426 | const startPlay = async () => { |
| 312 | - console.log('StudyDetailPage: startPlay 被调用'); | 427 | + console.log('StudyDetailPage: startPlay 被调用') |
| 313 | - isPlaying.value = true; | 428 | + isPlaying.value = true |
| 314 | 429 | ||
| 315 | // 等待 VideoPlayer 组件挂载并初始化完成 | 430 | // 等待 VideoPlayer 组件挂载并初始化完成 |
| 316 | - await nextTick(); | 431 | + await nextTick() |
| 317 | 432 | ||
| 318 | // 需要额外等待 VideoPlayer 组件的 handleMounted 完成 | 433 | // 需要额外等待 VideoPlayer 组件的 handleMounted 完成 |
| 319 | // 使用 setTimeout 确保播放器已经初始化 | 434 | // 使用 setTimeout 确保播放器已经初始化 |
| 320 | setTimeout(() => { | 435 | setTimeout(() => { |
| 321 | - console.log('StudyDetailPage: videoPlayerRef.value:', videoPlayerRef.value); | 436 | + console.log('StudyDetailPage: videoPlayerRef.value:', videoPlayerRef.value) |
| 322 | - console.log('StudyDetailPage: typeof videoPlayerRef.value?.play:', typeof videoPlayerRef.value?.play); | 437 | + console.log( |
| 438 | + 'StudyDetailPage: typeof videoPlayerRef.value?.play:', | ||
| 439 | + typeof videoPlayerRef.value?.play | ||
| 440 | + ) | ||
| 323 | if (videoPlayerRef.value) { | 441 | if (videoPlayerRef.value) { |
| 324 | - videoPlayerRef.value.play(); | 442 | + console.log('StudyDetailPage: 调用 play() 方法...') |
| 443 | + const playResult = videoPlayerRef.value.play() | ||
| 444 | + // play() 可能返回 Promise(Video.js)或 undefined(原生播放器) | ||
| 445 | + if (playResult && typeof playResult.then === 'function') { | ||
| 446 | + playResult | ||
| 447 | + .then(() => { | ||
| 448 | + console.log('✅ StudyDetailPage: play() 成功') | ||
| 449 | + }) | ||
| 450 | + .catch(err => { | ||
| 451 | + console.error('❌ StudyDetailPage: play() 失败:', err) | ||
| 452 | + console.error(' 错误名称:', err.name) | ||
| 453 | + console.error(' 错误消息:', err.message) | ||
| 454 | + // 常见原因: | ||
| 455 | + // - NotAllowedError: 用户没有与页面交互 | ||
| 456 | + // - NotSupportedError: 视频格式不支持 | ||
| 457 | + // - AbortError: 加载被中断 | ||
| 458 | + }) | ||
| 459 | + } else { | ||
| 460 | + console.log('✅ StudyDetailPage: play() 调用完成(原生播放器)') | ||
| 461 | + } | ||
| 325 | } else { | 462 | } else { |
| 326 | - console.error('StudyDetailPage: videoPlayerRef.value 不存在'); | 463 | + console.error('StudyDetailPage: videoPlayerRef.value 不存在') |
| 327 | } | 464 | } |
| 328 | - }, 300); // 增加延迟,确保播放器初始化完成 | 465 | + }, 300) // 增加延迟,确保播放器初始化完成 |
| 329 | -}; | 466 | +} |
| 330 | 467 | ||
| 331 | // 处理视频播放状态 | 468 | // 处理视频播放状态 |
| 332 | -const handleVideoPlay = (video) => { | 469 | +const handleVideoPlay = video => { |
| 333 | - isPlaying.value = true; | 470 | + isPlaying.value = true |
| 334 | // 学习时长埋点开始 | 471 | // 学习时长埋点开始 |
| 335 | - startAction(); | 472 | + startAction() |
| 336 | -}; | 473 | +} |
| 337 | 474 | ||
| 338 | -const handleVideoPause = (video) => { | 475 | +const handleVideoPause = video => { |
| 339 | // 保持视频播放器可见,只在初始状态显示封面 | 476 | // 保持视频播放器可见,只在初始状态显示封面 |
| 340 | // 学习时长埋点结束 | 477 | // 学习时长埋点结束 |
| 341 | - endAction(); | 478 | + endAction() |
| 342 | -}; | 479 | +} |
| 343 | 480 | ||
| 344 | // 弹窗视频播放控制函数 | 481 | // 弹窗视频播放控制函数 |
| 345 | const startPopupVideoPlay = async () => { | 482 | const startPopupVideoPlay = async () => { |
| 346 | - isPopupVideoPlaying.value = true; | 483 | + isPopupVideoPlaying.value = true |
| 347 | - await nextTick(); | 484 | + await nextTick() |
| 348 | if (popupVideoPlayerRef.value) { | 485 | if (popupVideoPlayerRef.value) { |
| 349 | - popupVideoPlayerRef.value.play(); | 486 | + popupVideoPlayerRef.value.play() |
| 350 | } | 487 | } |
| 351 | -}; | 488 | +} |
| 352 | 489 | ||
| 353 | -const handlePopupVideoPlay = (video) => { | 490 | +const handlePopupVideoPlay = video => { |
| 354 | - isPopupVideoPlaying.value = true; | 491 | + isPopupVideoPlaying.value = true |
| 355 | -}; | 492 | +} |
| 356 | 493 | ||
| 357 | -const handlePopupVideoPause = (video) => { | 494 | +const handlePopupVideoPause = video => { |
| 358 | // 保持视频播放器可见,只在初始状态显示封面 | 495 | // 保持视频播放器可见,只在初始状态显示封面 |
| 359 | -}; | 496 | +} |
| 360 | 497 | ||
| 361 | // 停止弹窗视频播放 | 498 | // 停止弹窗视频播放 |
| 362 | const stopPopupVideoPlay = () => { | 499 | const stopPopupVideoPlay = () => { |
| 363 | - console.log('停止弹窗视频播放'); | 500 | + console.log('停止弹窗视频播放') |
| 364 | if (popupVideoPlayerRef.value && typeof popupVideoPlayerRef.value.pause === 'function') { | 501 | if (popupVideoPlayerRef.value && typeof popupVideoPlayerRef.value.pause === 'function') { |
| 365 | - popupVideoPlayerRef.value.pause(); | 502 | + popupVideoPlayerRef.value.pause() |
| 366 | } | 503 | } |
| 367 | - isPopupVideoPlaying.value = false; | 504 | + isPopupVideoPlaying.value = false |
| 368 | -}; | 505 | +} |
| 369 | - | ||
| 370 | - | ||
| 371 | 506 | ||
| 372 | // 图片预览相关 | 507 | // 图片预览相关 |
| 373 | -const showPreview = ref(false); | 508 | +const showPreview = ref(false) |
| 374 | -const previewImages = ref([]); | 509 | +const previewImages = ref([]) |
| 375 | 510 | ||
| 376 | // 显示图片预览 | 511 | // 显示图片预览 |
| 377 | -const showImagePreview = (startPosition) => { | 512 | +const showImagePreview = startPosition => { |
| 378 | - previewImages.value = courseFile.value?.list?.map(item => item.url) || []; | 513 | + previewImages.value = courseFile.value?.list?.map(item => item.url) || [] |
| 379 | - showPreview.value = true; | 514 | + showPreview.value = true |
| 380 | -}; | 515 | +} |
| 381 | 516 | ||
| 382 | // 关闭图片预览 | 517 | // 关闭图片预览 |
| 383 | const closeImagePreview = () => { | 518 | const closeImagePreview = () => { |
| 384 | - showPreview.value = false; | 519 | + showPreview.value = false |
| 385 | -}; | 520 | +} |
| 386 | 521 | ||
| 387 | /** | 522 | /** |
| 388 | * @function handleCheckInSuccess | 523 | * @function handleCheckInSuccess |
| ... | @@ -391,41 +526,41 @@ const closeImagePreview = () => { | ... | @@ -391,41 +526,41 @@ const closeImagePreview = () => { |
| 391 | */ | 526 | */ |
| 392 | const handleCheckInSuccess = () => { | 527 | const handleCheckInSuccess = () => { |
| 393 | // 打卡成功轻提示 | 528 | // 打卡成功轻提示 |
| 394 | - showToast('打卡成功'); | 529 | + showToast('打卡成功') |
| 395 | -}; | 530 | +} |
| 396 | 531 | ||
| 397 | -const audioList = ref([]); | 532 | +const audioList = ref([]) |
| 398 | 533 | ||
| 399 | // 设置页面标题(动态显示课程名) | 534 | // 设置页面标题(动态显示课程名) |
| 400 | -const pageTitle = computed(() => course.value?.title || '学习详情'); | 535 | +const pageTitle = computed(() => course.value?.title || '学习详情') |
| 401 | -watch(pageTitle, (newTitle) => useTitle(newTitle), { immediate: true }); | 536 | +watch(pageTitle, newTitle => useTitle(newTitle), { immediate: true }) |
| 402 | 537 | ||
| 403 | -const topWrapperHeight = ref(0); | 538 | +const topWrapperHeight = ref(0) |
| 404 | -const bottomWrapperHeight = ref(0); | 539 | +const bottomWrapperHeight = ref(0) |
| 405 | 540 | ||
| 406 | // 标记是否由tab切换触发的滚动 | 541 | // 标记是否由tab切换触发的滚动 |
| 407 | -const isTabScrolling = ref(false); | 542 | +const isTabScrolling = ref(false) |
| 408 | 543 | ||
| 409 | const handleScroll = () => { | 544 | const handleScroll = () => { |
| 410 | // 如果是由tab切换触发的滚动,不处理 | 545 | // 如果是由tab切换触发的滚动,不处理 |
| 411 | - if (isTabScrolling.value) return; | 546 | + if (isTabScrolling.value) return |
| 412 | 547 | ||
| 413 | - const introElement = document.getElementById('intro'); | 548 | + const introElement = document.getElementById('intro') |
| 414 | - const commentElement = document.getElementById('comment'); | 549 | + const commentElement = document.getElementById('comment') |
| 415 | - if (!introElement || !commentElement) return; | 550 | + if (!introElement || !commentElement) return |
| 416 | 551 | ||
| 417 | - const scrollTop = window.scrollY; | 552 | + const scrollTop = window.scrollY |
| 418 | - const commentOffset = commentElement.offsetTop - parseInt(topWrapperHeight.value) - 20; // 20是一个偏移量 | 553 | + const commentOffset = commentElement.offsetTop - parseInt(topWrapperHeight.value) - 20 // 20是一个偏移量 |
| 419 | 554 | ||
| 420 | // 根据滚动位置更新activeTab | 555 | // 根据滚动位置更新activeTab |
| 421 | if (scrollTop >= commentOffset) { | 556 | if (scrollTop >= commentOffset) { |
| 422 | - activeTab.value = 'comments'; | 557 | + activeTab.value = 'comments' |
| 423 | } else { | 558 | } else { |
| 424 | - activeTab.value = 'intro'; | 559 | + activeTab.value = 'intro' |
| 425 | } | 560 | } |
| 426 | -}; | 561 | +} |
| 427 | 562 | ||
| 428 | -const courseFile = ref({}); | 563 | +const courseFile = ref({}) |
| 429 | 564 | ||
| 430 | /** | 565 | /** |
| 431 | * @function sync_study_course_back_position | 566 | * @function sync_study_course_back_position |
| ... | @@ -435,52 +570,52 @@ const courseFile = ref({}); | ... | @@ -435,52 +570,52 @@ const courseFile = ref({}); |
| 435 | * 注释:在用户点击目录项时调用,记录当前滚动位置和目录项ID | 570 | * 注释:在用户点击目录项时调用,记录当前滚动位置和目录项ID |
| 436 | */ | 571 | */ |
| 437 | 572 | ||
| 438 | -const sync_study_course_back_position = (lesson_id) => { | 573 | +const sync_study_course_back_position = lesson_id => { |
| 439 | - const from_course_id = route.query.from_course_id || ''; | 574 | + const from_course_id = route.query.from_course_id || '' |
| 440 | - if (!from_course_id) return; | 575 | + if (!from_course_id) return |
| 441 | 576 | ||
| 442 | - const key = `study_course_scroll_${from_course_id}`; | 577 | + const key = `study_course_scroll_${from_course_id}` |
| 443 | - const raw = sessionStorage.getItem(key); | 578 | + const raw = sessionStorage.getItem(key) |
| 444 | 579 | ||
| 445 | - let payload = null; | 580 | + let payload = null |
| 446 | try { | 581 | try { |
| 447 | - payload = raw ? JSON.parse(raw) : null; | 582 | + payload = raw ? JSON.parse(raw) : null |
| 448 | } catch (e) { | 583 | } catch (e) { |
| 449 | - payload = null; | 584 | + payload = null |
| 450 | } | 585 | } |
| 451 | 586 | ||
| 452 | if (!payload || typeof payload !== 'object') { | 587 | if (!payload || typeof payload !== 'object') { |
| 453 | - payload = { scroll_y: 0, lesson_id: '', saved_at: 0 }; | 588 | + payload = { scroll_y: 0, lesson_id: '', saved_at: 0 } |
| 454 | } | 589 | } |
| 455 | 590 | ||
| 456 | - payload.lesson_id = lesson_id || payload.lesson_id || ''; | 591 | + payload.lesson_id = lesson_id || payload.lesson_id || '' |
| 457 | - payload.saved_at = Date.now(); | 592 | + payload.saved_at = Date.now() |
| 458 | 593 | ||
| 459 | - sessionStorage.setItem(key, JSON.stringify(payload)); | 594 | + sessionStorage.setItem(key, JSON.stringify(payload)) |
| 460 | -}; | 595 | +} |
| 461 | 596 | ||
| 462 | watch( | 597 | watch( |
| 463 | () => route.params.id, | 598 | () => route.params.id, |
| 464 | - (val) => { | 599 | + val => { |
| 465 | - sync_study_course_back_position(val || ''); | 600 | + sync_study_course_back_position(val || '') |
| 466 | }, | 601 | }, |
| 467 | { immediate: true } | 602 | { immediate: true } |
| 468 | -); | 603 | +) |
| 469 | 604 | ||
| 470 | -const handleLessonClick = async (lesson) => { | 605 | +const handleLessonClick = async lesson => { |
| 471 | - showCatalog.value = false; // 关闭目录弹窗 | 606 | + showCatalog.value = false // 关闭目录弹窗 |
| 472 | - isPlaying.value = false; // 重置播放状态 | 607 | + isPlaying.value = false // 重置播放状态 |
| 473 | // 同步学习课程返回位置 | 608 | // 同步学习课程返回位置 |
| 474 | - sync_study_course_back_position(lesson.id); | 609 | + sync_study_course_back_position(lesson.id) |
| 475 | 610 | ||
| 476 | // 更新URL地址,不触发页面重新加载 | 611 | // 更新URL地址,不触发页面重新加载 |
| 477 | - router.replace({ params: { id: lesson.id } }); | 612 | + router.replace({ params: { id: lesson.id } }) |
| 478 | 613 | ||
| 479 | // 重新获取课程数据 | 614 | // 重新获取课程数据 |
| 480 | - const { code, data } = await getScheduleCourseAPI({ i: lesson.id }); | 615 | + const { code, data } = await getScheduleCourseAPI({ i: lesson.id }) |
| 481 | if (code === 1) { | 616 | if (code === 1) { |
| 482 | - course.value = data; | 617 | + course.value = data |
| 483 | - courseFile.value = data.file; | 618 | + courseFile.value = data.file |
| 484 | 619 | ||
| 485 | // 更新音频列表数据 | 620 | // 更新音频列表数据 |
| 486 | if (data.course_type === 'audio' && data.file?.list?.length) { | 621 | if (data.course_type === 'audio' && data.file?.list?.length) { |
| ... | @@ -489,68 +624,71 @@ const handleLessonClick = async (lesson) => { | ... | @@ -489,68 +624,71 @@ const handleLessonClick = async (lesson) => { |
| 489 | title: item.title || '未命名音频', | 624 | title: item.title || '未命名音频', |
| 490 | artist: '', | 625 | artist: '', |
| 491 | url: item.url, | 626 | url: item.url, |
| 492 | - cover: item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg' | 627 | + cover: item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg', |
| 493 | - })); | 628 | + })) |
| 494 | } else { | 629 | } else { |
| 495 | - audioList.value = []; | 630 | + audioList.value = [] |
| 496 | } | 631 | } |
| 497 | 632 | ||
| 498 | - await refreshComments(); | 633 | + await refreshComments() |
| 499 | 634 | ||
| 500 | // 重新计算顶部和底部容器的高度 | 635 | // 重新计算顶部和底部容器的高度 |
| 501 | nextTick(() => { | 636 | nextTick(() => { |
| 502 | - const topWrapper = document.querySelector('.top-wrapper'); | 637 | + const topWrapper = document.querySelector('.top-wrapper') |
| 503 | - const bottomWrapper = document.querySelector('.bottom-wrapper'); | 638 | + const bottomWrapper = document.querySelector('.bottom-wrapper') |
| 504 | if (topWrapper) { | 639 | if (topWrapper) { |
| 505 | - topWrapperHeight.value = topWrapper.clientHeight + 'px'; | 640 | + topWrapperHeight.value = `${topWrapper.clientHeight}px` |
| 506 | } | 641 | } |
| 507 | if (bottomWrapper) { | 642 | if (bottomWrapper) { |
| 508 | - bottomWrapperHeight.value = bottomWrapper.clientHeight + 'px'; | 643 | + bottomWrapperHeight.value = `${bottomWrapper.clientHeight}px` |
| 509 | } | 644 | } |
| 510 | - }); | 645 | + }) |
| 511 | 646 | ||
| 512 | // 图片附件或者附件不存在 | 647 | // 图片附件或者附件不存在 |
| 513 | // 进入后直接执行学习时长埋点 | 648 | // 进入后直接执行学习时长埋点 |
| 514 | if (course.value?.course_type === 'image' || !course.value?.course_type) { | 649 | if (course.value?.course_type === 'image' || !course.value?.course_type) { |
| 515 | // 新增记录 | 650 | // 新增记录 |
| 516 | - let paramsObj = { | 651 | + const paramsObj = { |
| 517 | schedule_id: courseId.value, | 652 | schedule_id: courseId.value, |
| 518 | } | 653 | } |
| 519 | - addRecord(paramsObj); | 654 | + addRecord(paramsObj) |
| 520 | } | 655 | } |
| 521 | } | 656 | } |
| 522 | -}; | 657 | +} |
| 523 | 658 | ||
| 524 | // Office 文档预览相关 | 659 | // Office 文档预览相关 |
| 525 | -const officeShow = ref(false); | 660 | +const officeShow = ref(false) |
| 526 | -const officeTitle = ref(''); | 661 | +const officeTitle = ref('') |
| 527 | -const officeUrl = ref(''); | 662 | +const officeUrl = ref('') |
| 528 | -const officeFileType = ref(''); | 663 | +const officeFileType = ref('') |
| 529 | 664 | ||
| 530 | // 音频播放器相关 | 665 | // 音频播放器相关 |
| 531 | -const audioShow = ref(false); | 666 | +const audioShow = ref(false) |
| 532 | -const audioTitle = ref(''); | 667 | +const audioTitle = ref('') |
| 533 | -const audioUrl = ref(''); | 668 | +const audioUrl = ref('') |
| 534 | 669 | ||
| 535 | // 视频播放器相关 | 670 | // 视频播放器相关 |
| 536 | -const videoShow = ref(false); | 671 | +const videoShow = ref(false) |
| 537 | -const videoTitle = ref(''); | 672 | +const videoTitle = ref('') |
| 538 | -const videoUrl = ref(''); | 673 | +const videoUrl = ref('') |
| 539 | -const isPopupVideoPlaying = ref(false); // 弹窗视频播放状态 | 674 | +const isPopupVideoPlaying = ref(false) // 弹窗视频播放状态 |
| 540 | -const popupVideoPlayerRef = ref(null); // 弹窗视频播放器引用 | 675 | +const popupVideoPlayerRef = ref(null) // 弹窗视频播放器引用 |
| 541 | 676 | ||
| 542 | const showPdf = ({ title, url, meta_id }) => { | 677 | const showPdf = ({ title, url, meta_id }) => { |
| 543 | // 跳转到PDF预览页面,并带上返回的课程ID和打开资料弹框的标记 | 678 | // 跳转到PDF预览页面,并带上返回的课程ID和打开资料弹框的标记 |
| 544 | - const encodedUrl = encodeURIComponent(url); | 679 | + const encodedUrl = encodeURIComponent(url) |
| 545 | - const encodedTitle = encodeURIComponent(title); | 680 | + const encodedTitle = encodeURIComponent(title) |
| 546 | - router.replace({ name: 'PdfPreview', query: { url: encodedUrl, title: encodedTitle, returnId: courseId.value, openMaterials: '1' } }); | 681 | + router.replace({ |
| 682 | + name: 'PdfPreview', | ||
| 683 | + query: { url: encodedUrl, title: encodedTitle, returnId: courseId.value, openMaterials: '1' }, | ||
| 684 | + }) | ||
| 547 | // 新增记录 | 685 | // 新增记录 |
| 548 | - let paramsObj = { | 686 | + const paramsObj = { |
| 549 | schedule_id: courseId.value, | 687 | schedule_id: courseId.value, |
| 550 | - meta_id | 688 | + meta_id, |
| 551 | } | 689 | } |
| 552 | - addRecord(paramsObj); | 690 | + addRecord(paramsObj) |
| 553 | -}; | 691 | +} |
| 554 | 692 | ||
| 555 | /** | 693 | /** |
| 556 | * 显示 Office 文档预览 | 694 | * 显示 Office 文档预览 |
| ... | @@ -597,138 +735,153 @@ const showPdf = ({ title, url, meta_id }) => { | ... | @@ -597,138 +735,153 @@ const showPdf = ({ title, url, meta_id }) => { |
| 597 | * @param {Object} file - 文件对象,包含title、url、meta_id | 735 | * @param {Object} file - 文件对象,包含title、url、meta_id |
| 598 | */ | 736 | */ |
| 599 | const showAudio = ({ title, url, meta_id }) => { | 737 | const showAudio = ({ title, url, meta_id }) => { |
| 600 | - audioTitle.value = title; | 738 | + audioTitle.value = title |
| 601 | - audioUrl.value = url; | 739 | + audioUrl.value = url |
| 602 | - audioShow.value = true; | 740 | + audioShow.value = true |
| 603 | // 新增记录 | 741 | // 新增记录 |
| 604 | - let paramsObj = { | 742 | + const paramsObj = { |
| 605 | schedule_id: courseId.value, | 743 | schedule_id: courseId.value, |
| 606 | - meta_id | 744 | + meta_id, |
| 607 | } | 745 | } |
| 608 | - addRecord(paramsObj); | 746 | + addRecord(paramsObj) |
| 609 | -}; | 747 | +} |
| 610 | 748 | ||
| 611 | /** | 749 | /** |
| 612 | * 显示视频播放器 | 750 | * 显示视频播放器 |
| 613 | * @param {Object} file - 文件对象,包含title、url、meta_id | 751 | * @param {Object} file - 文件对象,包含title、url、meta_id |
| 614 | */ | 752 | */ |
| 615 | const showVideo = ({ title, url, meta_id }) => { | 753 | const showVideo = ({ title, url, meta_id }) => { |
| 616 | - videoTitle.value = title; | 754 | + videoTitle.value = title |
| 617 | - videoUrl.value = url; | 755 | + videoUrl.value = url |
| 618 | - videoShow.value = true; | 756 | + videoShow.value = true |
| 619 | - isPopupVideoPlaying.value = false; // 重置播放状态 | 757 | + isPopupVideoPlaying.value = false // 重置播放状态 |
| 620 | // 新增记录 | 758 | // 新增记录 |
| 621 | - let paramsObj = { | 759 | + const paramsObj = { |
| 622 | schedule_id: courseId.value, | 760 | schedule_id: courseId.value, |
| 623 | - meta_id | 761 | + meta_id, |
| 624 | } | 762 | } |
| 625 | - addRecord(paramsObj); | 763 | + addRecord(paramsObj) |
| 626 | -}; | 764 | +} |
| 627 | 765 | ||
| 628 | // 监听弹窗关闭,停止视频播放 | 766 | // 监听弹窗关闭,停止视频播放 |
| 629 | -watch(videoShow, (newVal) => { | 767 | +watch(videoShow, newVal => { |
| 630 | if (!newVal) { | 768 | if (!newVal) { |
| 631 | // 弹窗关闭时停止视频播放 | 769 | // 弹窗关闭时停止视频播放 |
| 632 | - stopPopupVideoPlay(); | 770 | + stopPopupVideoPlay() |
| 633 | } | 771 | } |
| 634 | -}); | 772 | +}) |
| 635 | 773 | ||
| 636 | /** | 774 | /** |
| 637 | * 显示图片预览 | 775 | * 显示图片预览 |
| 638 | * @param {Object} file - 文件对象,包含title、url、meta_id | 776 | * @param {Object} file - 文件对象,包含title、url、meta_id |
| 639 | */ | 777 | */ |
| 640 | const showImage = ({ title, url, meta_id }) => { | 778 | const showImage = ({ title, url, meta_id }) => { |
| 641 | - previewImages.value = [url]; | 779 | + previewImages.value = [url] |
| 642 | - showPreview.value = true; | 780 | + showPreview.value = true |
| 643 | // 新增记录 | 781 | // 新增记录 |
| 644 | - let paramsObj = { | 782 | + const paramsObj = { |
| 645 | schedule_id: courseId.value, | 783 | schedule_id: courseId.value, |
| 646 | - meta_id | 784 | + meta_id, |
| 647 | } | 785 | } |
| 648 | - addRecord(paramsObj); | 786 | + addRecord(paramsObj) |
| 649 | -}; | 787 | +} |
| 650 | 788 | ||
| 651 | -const onPdfLoad = (load) => { | 789 | +const onPdfLoad = load => { |
| 652 | // console.warn('pdf加载状态', load); | 790 | // console.warn('pdf加载状态', load); |
| 653 | -}; | 791 | +} |
| 654 | 792 | ||
| 655 | /** | 793 | /** |
| 656 | * PDF下载进度回调 | 794 | * PDF下载进度回调 |
| 657 | * @param {number} progress - 下载进度 0-100 | 795 | * @param {number} progress - 下载进度 0-100 |
| 658 | */ | 796 | */ |
| 659 | -const onPdfProgress = (progress) => { | 797 | +const onPdfProgress = progress => { |
| 660 | // console.log('PDF下载进度:', progress); | 798 | // console.log('PDF下载进度:', progress); |
| 661 | -}; | 799 | +} |
| 662 | 800 | ||
| 663 | /** | 801 | /** |
| 664 | * PDF下载完成回调 | 802 | * PDF下载完成回调 |
| 665 | */ | 803 | */ |
| 666 | const onPdfComplete = () => { | 804 | const onPdfComplete = () => { |
| 667 | // console.log('PDF下载完成'); | 805 | // console.log('PDF下载完成'); |
| 668 | -}; | 806 | +} |
| 669 | 807 | ||
| 670 | /** | 808 | /** |
| 671 | * Office 文档渲染完成回调 | 809 | * Office 文档渲染完成回调 |
| 672 | */ | 810 | */ |
| 673 | const onOfficeRendered = () => { | 811 | const onOfficeRendered = () => { |
| 674 | - console.log('Office 文档渲染完成'); | 812 | + console.log('Office 文档渲染完成') |
| 675 | // showToast('文档加载完成'); | 813 | // showToast('文档加载完成'); |
| 676 | -}; | 814 | +} |
| 677 | 815 | ||
| 678 | /** | 816 | /** |
| 679 | * Office 文档渲染错误回调 | 817 | * Office 文档渲染错误回调 |
| 680 | * @param {Error} error - 错误对象 | 818 | * @param {Error} error - 错误对象 |
| 681 | */ | 819 | */ |
| 682 | -const onOfficeError = (error) => { | 820 | +const onOfficeError = error => { |
| 683 | - console.error('Office 文档渲染失败:', error); | 821 | + console.error('Office 文档渲染失败:', error) |
| 684 | - showToast('文档加载失败,请重试'); | 822 | + showToast('文档加载失败,请重试') |
| 685 | -}; | 823 | +} |
| 686 | 824 | ||
| 687 | /** | 825 | /** |
| 688 | * Office 文档重试回调 | 826 | * Office 文档重试回调 |
| 689 | */ | 827 | */ |
| 690 | const onOfficeRetry = () => { | 828 | const onOfficeRetry = () => { |
| 691 | - console.log('重试加载 Office 文档'); | 829 | + console.log('重试加载 Office 文档') |
| 692 | // 可以在这里添加重新获取文档的逻辑 | 830 | // 可以在这里添加重新获取文档的逻辑 |
| 693 | -}; | 831 | +} |
| 694 | 832 | ||
| 695 | -const courseId = computed(() => { | 833 | +const courseId = computed(() => route.params.id || '') |
| 696 | - return route.params.id || ''; | ||
| 697 | -}); | ||
| 698 | 834 | ||
| 699 | const { startAction, endAction, addRecord } = useStudyRecordTracker({ | 835 | const { startAction, endAction, addRecord } = useStudyRecordTracker({ |
| 700 | course, | 836 | course, |
| 701 | courseId, | 837 | courseId, |
| 702 | videoPlayerRef, | 838 | videoPlayerRef, |
| 703 | - audioPlayerRef | 839 | + audioPlayerRef, |
| 704 | -}); | 840 | +}) |
| 841 | + | ||
| 842 | +/** | ||
| 843 | + * @function preloadVideoJs | ||
| 844 | + * @description 预加载 Video.js 资源,避免首次播放时黑屏 | ||
| 845 | + * @returns {void} | ||
| 846 | + */ | ||
| 847 | +const preloadVideoJs = () => { | ||
| 848 | + // 在用户可能点击视频前,预先加载 Video.js 资源 | ||
| 849 | + import('video.js/dist/video-js.css') | ||
| 850 | + import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css') | ||
| 851 | + import('videojs-contrib-quality-levels') | ||
| 852 | + import('videojs-hls-quality-selector') | ||
| 853 | + import('@videojs-player/vue') | ||
| 854 | +} | ||
| 705 | 855 | ||
| 706 | onMounted(async () => { | 856 | onMounted(async () => { |
| 857 | + // 预加载 Video.js 资源,避免首次播放时黑屏 | ||
| 858 | + preloadVideoJs() | ||
| 859 | + | ||
| 707 | // 延迟设置topWrapper和bottomWrapper的高度 | 860 | // 延迟设置topWrapper和bottomWrapper的高度 |
| 708 | setTimeout(() => { | 861 | setTimeout(() => { |
| 709 | nextTick(() => { | 862 | nextTick(() => { |
| 710 | - const topWrapper = document.querySelector('.top-wrapper'); | 863 | + const topWrapper = document.querySelector('.top-wrapper') |
| 711 | - const bottomWrapper = document.querySelector('.bottom-wrapper'); | 864 | + const bottomWrapper = document.querySelector('.bottom-wrapper') |
| 712 | if (topWrapper) { | 865 | if (topWrapper) { |
| 713 | - topWrapperHeight.value = topWrapper.clientHeight + 'px'; | 866 | + topWrapperHeight.value = `${topWrapper.clientHeight}px` |
| 714 | } | 867 | } |
| 715 | if (bottomWrapper) { | 868 | if (bottomWrapper) { |
| 716 | - bottomWrapperHeight.value = bottomWrapper.clientHeight + 'px'; | 869 | + bottomWrapperHeight.value = `${bottomWrapper.clientHeight}px` |
| 717 | } | 870 | } |
| 718 | 871 | ||
| 719 | // 添加滚动监听 | 872 | // 添加滚动监听 |
| 720 | - window.addEventListener('scroll', handleScroll); | 873 | + window.addEventListener('scroll', handleScroll) |
| 721 | }) | 874 | }) |
| 722 | - }, 500); | 875 | + }, 500) |
| 723 | 876 | ||
| 724 | if (courseId.value) { | 877 | if (courseId.value) { |
| 725 | - const { code, data } = await getScheduleCourseAPI({ i: courseId.value }); | 878 | + const { code, data } = await getScheduleCourseAPI({ i: courseId.value }) |
| 726 | if (code === 1) { | 879 | if (code === 1) { |
| 727 | - course.value = data; | 880 | + course.value = data |
| 728 | - courseFile.value = data.file; | 881 | + courseFile.value = data.file |
| 729 | // 课程大纲细项打卡互动 | 882 | // 课程大纲细项打卡互动 |
| 730 | - task_list.value = []; | 883 | + task_list.value = [] |
| 731 | - timeout_task_list.value = []; | 884 | + timeout_task_list.value = [] |
| 732 | 885 | ||
| 733 | // 处理task_list数据格式 | 886 | // 处理task_list数据格式 |
| 734 | task_list.value = normalizeCheckinTaskItems(course.value?.task_list) | 887 | task_list.value = normalizeCheckinTaskItems(course.value?.task_list) |
| ... | @@ -738,87 +891,90 @@ onMounted(async () => { | ... | @@ -738,87 +891,90 @@ onMounted(async () => { |
| 738 | 891 | ||
| 739 | // 音频列表处理 | 892 | // 音频列表处理 |
| 740 | if (data.course_type === 'audio') { | 893 | if (data.course_type === 'audio') { |
| 741 | - audioList.value = data.file.list; | 894 | + audioList.value = data.file.list |
| 742 | data.file.list.forEach((item, index) => { | 895 | data.file.list.forEach((item, index) => { |
| 743 | item.cover = 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg' | 896 | item.cover = 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg' |
| 744 | }) | 897 | }) |
| 745 | } | 898 | } |
| 746 | 899 | ||
| 747 | // 刷新评论列表 | 900 | // 刷新评论列表 |
| 748 | - await refreshComments(); | 901 | + await refreshComments() |
| 749 | 902 | ||
| 750 | // 获取课程信息 | 903 | // 获取课程信息 |
| 751 | - const detail = await getCourseDetailAPI({ i: course.value.group_id }); | 904 | + const detail = await getCourseDetailAPI({ i: course.value.group_id }) |
| 752 | if (detail.code === 1) { | 905 | if (detail.code === 1) { |
| 753 | // 课程目录 | 906 | // 课程目录 |
| 754 | - course_lessons.value = detail.data.schedule || []; | 907 | + course_lessons.value = detail.data.schedule || [] |
| 755 | } | 908 | } |
| 756 | } | 909 | } |
| 757 | // 图片附件或者附件不存在 | 910 | // 图片附件或者附件不存在 |
| 758 | // 进入后直接执行学习时长埋点 | 911 | // 进入后直接执行学习时长埋点 |
| 759 | if (course.value.course_type === 'image' || !course.value.course_type) { | 912 | if (course.value.course_type === 'image' || !course.value.course_type) { |
| 760 | // 新增记录 | 913 | // 新增记录 |
| 761 | - let paramsObj = { | 914 | + const paramsObj = { |
| 762 | schedule_id: courseId.value, | 915 | schedule_id: courseId.value, |
| 763 | } | 916 | } |
| 764 | - addRecord(paramsObj); | 917 | + addRecord(paramsObj) |
| 765 | } | 918 | } |
| 766 | } | 919 | } |
| 767 | -}); | 920 | +}) |
| 768 | 921 | ||
| 769 | // 处理标签页切换 | 922 | // 处理标签页切换 |
| 770 | -const handleTabChange = (name) => { | 923 | +const handleTabChange = name => { |
| 771 | // 先更新activeTab值 | 924 | // 先更新activeTab值 |
| 772 | - activeTab.value = name; | 925 | + activeTab.value = name |
| 773 | 926 | ||
| 774 | // 设置标记,表示这是由tab切换触发的滚动 | 927 | // 设置标记,表示这是由tab切换触发的滚动 |
| 775 | - isTabScrolling.value = true; | 928 | + isTabScrolling.value = true |
| 776 | 929 | ||
| 777 | // 然后执行滚动操作 | 930 | // 然后执行滚动操作 |
| 778 | nextTick(() => { | 931 | nextTick(() => { |
| 779 | - const element = document.getElementById(name === 'intro' ? 'intro' : 'comment'); | 932 | + const element = document.getElementById(name === 'intro' ? 'intro' : 'comment') |
| 780 | if (element) { | 933 | if (element) { |
| 781 | - const topOffset = element.offsetTop - parseInt(topWrapperHeight.value); | 934 | + const topOffset = element.offsetTop - parseInt(topWrapperHeight.value) |
| 782 | window.scrollTo({ | 935 | window.scrollTo({ |
| 783 | top: topOffset, | 936 | top: topOffset, |
| 784 | - behavior: 'smooth' | 937 | + behavior: 'smooth', |
| 785 | - }); | 938 | + }) |
| 786 | 939 | ||
| 787 | // 滚动动画结束后重置标记 | 940 | // 滚动动画结束后重置标记 |
| 788 | setTimeout(() => { | 941 | setTimeout(() => { |
| 789 | - isTabScrolling.value = false; | 942 | + isTabScrolling.value = false |
| 790 | - }, 500); // 假设滚动动画持续500ms | 943 | + }, 500) // 假设滚动动画持续500ms |
| 791 | } else { | 944 | } else { |
| 792 | - isTabScrolling.value = false; | 945 | + isTabScrolling.value = false |
| 793 | } | 946 | } |
| 794 | - }); | 947 | + }) |
| 795 | -}; | 948 | +} |
| 796 | 949 | ||
| 797 | // 在组件卸载时移除滚动监听 | 950 | // 在组件卸载时移除滚动监听 |
| 798 | onUnmounted(() => { | 951 | onUnmounted(() => { |
| 799 | - window.removeEventListener('scroll', handleScroll); | 952 | + window.removeEventListener('scroll', handleScroll) |
| 800 | - endAction(); | 953 | + endAction() |
| 801 | -}); | 954 | +}) |
| 802 | 955 | ||
| 803 | // 学习资料弹窗状态 | 956 | // 学习资料弹窗状态 |
| 804 | -const showMaterialsPopup = ref(false); | 957 | +const showMaterialsPopup = ref(false) |
| 805 | 958 | ||
| 806 | // 路由参数监听:如果openMaterials=1,则打开学习资料弹框 | 959 | // 路由参数监听:如果openMaterials=1,则打开学习资料弹框 |
| 807 | -watch(() => route.query.openMaterials, (val) => { | 960 | +watch( |
| 961 | + () => route.query.openMaterials, | ||
| 962 | + val => { | ||
| 808 | if (val === '1' || val === 1 || val === true) { | 963 | if (val === '1' || val === 1 || val === true) { |
| 809 | - showMaterialsPopup.value = true; | 964 | + showMaterialsPopup.value = true |
| 810 | } | 965 | } |
| 811 | -}, { immediate: true }); | 966 | + }, |
| 967 | + { immediate: true } | ||
| 968 | +) | ||
| 812 | 969 | ||
| 813 | // 监听弹框关闭:关闭时移除URL中的openMaterials参数,防止刷新再次打开 | 970 | // 监听弹框关闭:关闭时移除URL中的openMaterials参数,防止刷新再次打开 |
| 814 | watch(showMaterialsPopup, (val, oldVal) => { | 971 | watch(showMaterialsPopup, (val, oldVal) => { |
| 815 | if (oldVal && !val) { | 972 | if (oldVal && !val) { |
| 816 | - const newQuery = { ...route.query }; | 973 | + const newQuery = { ...route.query } |
| 817 | - delete newQuery.openMaterials; | 974 | + delete newQuery.openMaterials |
| 818 | - router.replace({ path: route.path, query: newQuery }); | 975 | + router.replace({ path: route.path, query: newQuery }) |
| 819 | } | 976 | } |
| 820 | -}); | 977 | +}) |
| 821 | - | ||
| 822 | 978 | ||
| 823 | /** | 979 | /** |
| 824 | * 在新窗口中打开文件 | 980 | * 在新窗口中打开文件 |
| ... | @@ -851,7 +1007,6 @@ watch(showMaterialsPopup, (val, oldVal) => { | ... | @@ -851,7 +1007,6 @@ watch(showMaterialsPopup, (val, oldVal) => { |
| 851 | // return supportedTypes.includes(extension); | 1007 | // return supportedTypes.includes(extension); |
| 852 | // } | 1008 | // } |
| 853 | 1009 | ||
| 854 | - | ||
| 855 | /** | 1010 | /** |
| 856 | * 判断文件是否为 Office 文档 | 1011 | * 判断文件是否为 Office 文档 |
| 857 | * @param {string} fileName - 文件名 | 1012 | * @param {string} fileName - 文件名 |
| ... | @@ -895,53 +1050,53 @@ watch(showMaterialsPopup, (val, oldVal) => { | ... | @@ -895,53 +1050,53 @@ watch(showMaterialsPopup, (val, oldVal) => { |
| 895 | * @param audio 音频对象 | 1050 | * @param audio 音频对象 |
| 896 | */ | 1051 | */ |
| 897 | const onAudioPlay = (audio, meta_id) => { | 1052 | const onAudioPlay = (audio, meta_id) => { |
| 898 | - console.log('开始播放音频', audio); | 1053 | + console.log('开始播放音频', audio) |
| 899 | // 学习时长埋点开始 | 1054 | // 学习时长埋点开始 |
| 900 | - startAction({ meta_id }); | 1055 | + startAction({ meta_id }) |
| 901 | } | 1056 | } |
| 902 | 1057 | ||
| 903 | /** | 1058 | /** |
| 904 | * 音频暂停事件 | 1059 | * 音频暂停事件 |
| 905 | * @param audio 音频对象 | 1060 | * @param audio 音频对象 |
| 906 | */ | 1061 | */ |
| 907 | -const onAudioPause = (audio) => { | 1062 | +const onAudioPause = audio => { |
| 908 | - console.log('暂停播放音频', audio); | 1063 | + console.log('暂停播放音频', audio) |
| 909 | // 学习时长埋点结束 | 1064 | // 学习时长埋点结束 |
| 910 | - endAction(); | 1065 | + endAction() |
| 911 | } | 1066 | } |
| 912 | 1067 | ||
| 913 | // 打卡相关状态 | 1068 | // 打卡相关状态 |
| 914 | -const showCheckInDialog = ref(false); | 1069 | +const showCheckInDialog = ref(false) |
| 915 | -const task_list = ref([]); | 1070 | +const task_list = ref([]) |
| 916 | -const timeout_task_list = ref([]); | 1071 | +const timeout_task_list = ref([]) |
| 917 | // 统一弹窗后不再维护默认列表与切换状态 | 1072 | // 统一弹窗后不再维护默认列表与切换状态 |
| 918 | 1073 | ||
| 919 | // 处理打卡选择 | 1074 | // 处理打卡选择 |
| 920 | const goToCheckin = () => { | 1075 | const goToCheckin = () => { |
| 921 | if (!(task_list.value.length || timeout_task_list.value.length)) { | 1076 | if (!(task_list.value.length || timeout_task_list.value.length)) { |
| 922 | - showToast('暂无打卡任务'); | 1077 | + showToast('暂无打卡任务') |
| 923 | - return; | 1078 | + return |
| 924 | } | 1079 | } |
| 925 | - showCheckInDialog.value = true; | 1080 | + showCheckInDialog.value = true |
| 926 | -}; | 1081 | +} |
| 927 | 1082 | ||
| 928 | /** | 1083 | /** |
| 929 | * 格式化文件大小 | 1084 | * 格式化文件大小 |
| 930 | * @param {number} size - 文件大小(字节) | 1085 | * @param {number} size - 文件大小(字节) |
| 931 | * @returns {string} 格式化后的文件大小 | 1086 | * @returns {string} 格式化后的文件大小 |
| 932 | */ | 1087 | */ |
| 933 | -const formatFileSize = (size) => { | 1088 | +const formatFileSize = size => { |
| 934 | - if (!size) return '0 B'; | 1089 | + if (!size) return '0 B' |
| 935 | - const units = ['B', 'KB', 'MB', 'GB']; | 1090 | + const units = ['B', 'KB', 'MB', 'GB'] |
| 936 | - let index = 0; | 1091 | + let index = 0 |
| 937 | - let fileSize = size; | 1092 | + let fileSize = size |
| 938 | 1093 | ||
| 939 | while (fileSize >= 1024 && index < units.length - 1) { | 1094 | while (fileSize >= 1024 && index < units.length - 1) { |
| 940 | - fileSize /= 1024; | 1095 | + fileSize /= 1024 |
| 941 | - index++; | 1096 | + index++ |
| 942 | } | 1097 | } |
| 943 | 1098 | ||
| 944 | - return `${fileSize.toFixed(1)} ${units[index]}`; | 1099 | + return `${fileSize.toFixed(1)} ${units[index]}` |
| 945 | } | 1100 | } |
| 946 | </script> | 1101 | </script> |
| 947 | 1102 | ... | ... |
-
Please register or login to post a comment