hookehuyr

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

- 在HomePage和StudyDetailPage添加Video.js资源预加载函数,解决首次播放黑屏问题
- 为VideoPlayer组件添加加载状态占位符,改善用户体验
- 增强视频播放错误处理和调试日志,便于问题排查
- 修复play()方法调用时的Promise处理,避免静默失败
- 优化跨域视频加载配置和网络超时设置
...@@ -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
......