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,51 +3,83 @@ ...@@ -3,51 +3,83 @@
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' }"> 13 + <div
14 - <img v-if="courseFile.cover" :src="courseFile.cover" :alt="course.title" class="w-full h-full object-cover" /> 14 + v-if="!isPlaying"
15 - <div class="absolute inset-0 flex items-center justify-center cursor-pointer" 15 + class="relative w-full"
16 - @click="startPlay"> 16 + :style="{
17 - <div 17 + aspectRatio: '16/9',
18 - class="w-20 h-20 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors"> 18 + backgroundColor: courseFile.cover ? 'transparent' : '#000',
19 - <font-awesome-icon icon="circle-play" class="text-5xl text-white" 19 + }"
20 - style="font-size: 3rem;" /> 20 + >
21 - </div> 21 + <img
22 - </div> 22 + v-if="courseFile.cover"
23 - </div> 23 + :src="courseFile.cover"
24 - <!-- 视频播放器 --> 24 + :alt="course.title"
25 - <VideoPlayer v-if="isPlaying" ref="videoPlayerRef" 25 + class="h-full w-full object-cover"
26 - :video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''" :autoplay="false" 26 + />
27 - :video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''" 27 + <div
28 - :use-native-on-ios="false" 28 + class="absolute inset-0 flex cursor-pointer items-center justify-center"
29 - @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> 29 + @click="startPlay"
30 - </div> 30 + >
31 - <div v-if="course.course_type === 'audio'" class="w-full relative border-b border-gray-200"> 31 + <div
32 - <!-- 音频播放器 --> 32 + class="flex h-20 w-20 items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70"
33 - <AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay" 33 + >
34 - @pause="onAudioPause" /> 34 + <font-awesome-icon
35 - </div> 35 + icon="circle-play"
36 - <!-- 图片列表展示区域 --> 36 + class="text-5xl text-white"
37 - <div v-if="course.course_type === 'image'" class="w-full relative"> 37 + style="font-size: 3rem"
38 - <van-swipe class="w-full" :autoplay="0" :show-indicators="true"> 38 + />
39 - <van-swipe-item v-for="(item, index) in courseFile?.list" :key="index" 39 + </div>
40 - @click.native="showImagePreview(index)"> 40 + </div>
41 - <van-image :src="item.url" class="w-full" fit="cover" style="aspect-ratio: 16/9;"> 41 + </div>
42 - <template #image> 42 + <!-- 视频播放器 -->
43 - <img :src="item.url" class="w-full h-full object-cover" /> 43 + <VideoPlayer
44 - </template> 44 + v-if="isPlaying"
45 - </van-image> 45 + ref="videoPlayerRef"
46 - </van-swipe-item> 46 + :video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''"
47 - </van-swipe> 47 + :autoplay="false"
48 - </div> 48 + :video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''"
49 - <!-- 文件列表展示区域 --> 49 + :use-native-on-ios="false"
50 - <!-- <div v-if="course.course_type === 'file'" class="w-full relative bg-white rounded-lg shadow-sm"> 50 + :debug="false"
51 + @onPlay="handleVideoPlay"
52 + @onPause="handleVideoPause"
53 + />
54 + </div>
55 + <div v-if="course.course_type === 'audio'" class="relative w-full border-b border-gray-200">
56 + <!-- 音频播放器 -->
57 + <AudioPlayer
58 + ref="audioPlayerRef"
59 + v-if="audioList.length"
60 + :songs="audioList"
61 + @play="onAudioPlay"
62 + @pause="onAudioPause"
63 + />
64 + </div>
65 + <!-- 图片列表展示区域 -->
66 + <div v-if="course.course_type === 'image'" class="relative w-full">
67 + <van-swipe class="w-full" :autoplay="0" :show-indicators="true">
68 + <van-swipe-item
69 + v-for="(item, index) in courseFile?.list"
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">
74 + <template #image>
75 + <img :src="item.url" class="h-full w-full object-cover" />
76 + </template>
77 + </van-image>
78 + </van-swipe-item>
79 + </van-swipe>
80 + </div>
81 + <!-- 文件列表展示区域 -->
82 + <!-- <div v-if="course.course_type === 'file'" class="w-full relative bg-white rounded-lg shadow-sm">
51 <div class="p-4 space-y-3"> 83 <div class="p-4 space-y-3">
52 <div v-for="(item, index) in courseFile?.list" :key="index" 84 <div v-for="(item, index) in courseFile?.list" :key="index"
53 class="group hover:bg-gray-50 transition-colors rounded-lg p-3"> 85 class="group hover:bg-gray-50 transition-colors rounded-lg p-3">
...@@ -67,115 +99,160 @@ ...@@ -67,115 +99,160 @@
67 </div> 99 </div>
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
82 - <van-tab :title-style="{ 'min-width': '50%' }" name="comments"> 114 + swipeable
83 - <template #title>评论({{ commentCount }})</template> 115 + shrink
84 - </van-tab> 116 + color="#4caf50"
85 - </van-tabs> 117 + @change="handleTabChange"
86 - <div v-if="task_list.length > 0" @click="goToCheckin" 118 + >
87 - style="position: absolute; right: 1rem; top: 1.5rem; font-size: 0.875rem; color: #666;">打卡互动 119 + <van-tab title="介绍" name="intro"> </van-tab>
88 - </div> 120 + <van-tab :title-style="{ 'min-width': '50%' }" name="comments">
121 + <template #title>评论({{ commentCount }})</template>
122 + </van-tab>
123 + </van-tabs>
124 + <div
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 + 打卡互动
130 + </div>
131 + </div>
132 + </div>
133 +
134 + <!-- 滚动区域:介绍和评论内容 -->
135 + <div
136 + class="flex-1 overflow-y-auto"
137 + :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }"
138 + >
139 + <div id="intro" class="px-4 py-4">
140 + <h1 class="mb-2 text-lg font-bold">{{ course.title }}</h1>
141 + <div class="flex items-center gap-2 text-sm text-gray-500">
142 + <span
143 + >开课时间
144 + {{
145 + course.schedule_time
146 + ? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss')
147 + : '暂无'
148 + }}</span
149 + >
150 + <!-- <span class="text-gray-300">|</span> -->
151 + <!-- <span>没有字段{{ course.studyCount || 0 }}次学习</span> -->
152 + </div>
153 +
154 + <!-- 学习资料入口 -->
155 + <div
156 + v-if="course.course_type === 'file' && courseFile?.list && courseFile.list.length > 0"
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 + >
160 + <div class="flex items-center justify-between">
161 + <div class="flex items-center gap-3">
162 + <div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-600">
163 + <van-icon name="notes" class="text-white" size="20" />
89 </div> 164 </div>
90 - </div> 165 + <div>
91 - 166 + <div class="text-base font-medium text-gray-900">学习资料</div>
92 - <!-- 滚动区域:介绍和评论内容 --> 167 + <div class="text-sm text-gray-500">共{{ courseFile.list.length }}个文件</div>
93 - <div class="overflow-y-auto flex-1"
94 - :style="{ paddingTop: topWrapperHeight, height: 'calc(100vh - ' + topWrapperHeight + ')' }">
95 - <div id="intro" class="py-4 px-4">
96 - <h1 class="text-lg font-bold mb-2">{{ course.title }}</h1>
97 - <div class="text-gray-500 text-sm flex items-center gap-2">
98 - <span>开课时间 {{ course.schedule_time ? dayjs(course.schedule_time).format('YYYY-MM-DD HH:mm:ss') :
99 - '暂无'
100 - }}</span>
101 - <!-- <span class="text-gray-300">|</span> -->
102 - <!-- <span>没有字段{{ course.studyCount || 0 }}次学习</span> -->
103 - </div>
104 -
105 - <!-- 学习资料入口 -->
106 - <div v-if="course.course_type === 'file' && courseFile?.list && courseFile.list.length > 0"
107 - class="bg-white rounded-lg p-4 mb-4 cursor-pointer hover:bg-gray-50 transition-colors mt-4 shadow-sm"
108 - @click="showMaterialsPopup = true">
109 - <div class="flex items-center justify-between">
110 - <div class="flex items-center gap-3">
111 - <div class="w-10 h-10 bg-green-600 rounded-lg flex items-center justify-center">
112 - <van-icon name="notes" class="text-white" size="20" />
113 - </div>
114 - <div>
115 - <div class="text-base font-medium text-gray-900">学习资料</div>
116 - <div class="text-sm text-gray-500">共{{ courseFile.list.length }}个文件</div>
117 - </div>
118 - </div>
119 - <van-icon name="arrow" class="text-gray-400" />
120 - </div>
121 - </div>
122 </div> 168 </div>
123 - 169 + </div>
124 - <div class="h-2 bg-gray-100"></div> 170 + <van-icon name="arrow" class="text-gray-400" />
125 - <!-- 评论区 -->
126 - <StudyCommentsSection :comment-count="commentCount" :comment-list="commentList"
127 - :popup-comment-list="popupCommentList" :popup-finished="popupFinished"
128 - :bottom-wrapper-height="bottomWrapperHeight" v-model:showCommentPopup="showCommentPopup"
129 - v-model:popupLoading="popupLoading" v-model:popupComment="popupComment" @toggleLike="toggleLike"
130 - @popupLoad="onPopupLoad" @submitPopupComment="submitPopupComment"
131 - @commentDeleted="handleCommentDeleted" />
132 </div> 171 </div>
172 + </div>
173 + </div>
133 174
134 - <!-- 底部操作栏 --> 175 + <div class="h-2 bg-gray-100"></div>
135 - <div 176 + <!-- 评论区 -->
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"> 177 + <StudyCommentsSection
137 - <div class="flex-none flex flex-col items-center gap-1 cursor-pointer active:opacity-80" 178 + :comment-count="commentCount"
138 - @click="showCatalog = true"> 179 + :comment-list="commentList"
139 - <van-icon name="bars" class="text-lg text-gray-600" /> 180 + :popup-comment-list="popupCommentList"
140 - <span class="text-xs text-gray-600">课程目录</span> 181 + :popup-finished="popupFinished"
141 - </div> 182 + :bottom-wrapper-height="bottomWrapperHeight"
142 - <div class="flex-grow flex-1 min-w-0"> 183 + v-model:showCommentPopup="showCommentPopup"
143 - <!-- <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言" 184 + v-model:popupLoading="popupLoading"
185 + v-model:popupComment="popupComment"
186 + @toggleLike="toggleLike"
187 + @popupLoad="onPopupLoad"
188 + @submitPopupComment="submitPopupComment"
189 + @commentDeleted="handleCommentDeleted"
190 + />
191 + </div>
192 +
193 + <!-- 底部操作栏 -->
194 + <div
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"
196 + >
197 + <div
198 + class="flex flex-none cursor-pointer flex-col items-center gap-1 active:opacity-80"
199 + @click="showCatalog = true"
200 + >
201 + <van-icon name="bars" class="text-lg text-gray-600" />
202 + <span class="text-xs text-gray-600">课程目录</span>
203 + </div>
204 + <div class="min-w-0 flex-1 flex-grow">
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>
146 <textarea v-model="newComment" rows="1" placeholder="请输入留言" 208 <textarea v-model="newComment" rows="1" placeholder="请输入留言"
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"
152 - </div> 214 + rows="1"
153 - <van-button type="primary" size="small" @click="submitComment">发送</van-button> 215 + autosize
154 - </div> 216 + type="textarea"
217 + placeholder="请输入评论"
218 + class="flex-1 rounded-lg bg-gray-100"
219 + />
155 </div> 220 </div>
221 + <van-button type="primary" size="small" @click="submitComment">发送</van-button>
222 + </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"
160 - <template #image="{ src, style, onLoad }"> 228 + :images="previewImages"
161 - <img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" /> 229 + :show-index="false"
162 - </template> 230 + :close-on-click-image="false"
163 - <template #cover> 231 + >
164 - <!-- 关闭按钮 --> 232 + <template #image="{ src, style, onLoad }">
165 - <div class="image-preview-close-btn" @click="closeImagePreview"> 233 + <img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" />
166 - <van-icon name="cross" size="20" color="#fff" /> 234 + </template>
167 - </div> 235 + <template #cover>
168 - </template> 236 + <!-- 关闭按钮 -->
169 - </van-image-preview> 237 + <div class="image-preview-close-btn" @click="closeImagePreview">
170 - 238 + <van-icon name="cross" size="20" color="#fff" />
171 - <!-- 课程目录弹窗 --> 239 + </div>
172 - <StudyCatalogPopup v-model:showCatalog="showCatalog" :lessons="course_lessons" :course-id="courseId" 240 + </template>
173 - :course-type-maps="course_type_maps" @lessonClick="handleLessonClick" /> 241 + </van-image-preview>
174 - 242 +
175 - <!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview --> 243 + <!-- 课程目录弹窗 -->
176 - 244 + <StudyCatalogPopup
177 - <!-- Office 文档预览弹窗 --> 245 + v-model:showCatalog="showCatalog"
178 - <!--<van-popup v-model:show="officeShow" position="center" round closeable :style="{ height: '80%', width: '90%' }"> 246 + :lessons="course_lessons"
247 + :course-id="courseId"
248 + :course-type-maps="course_type_maps"
249 + @lessonClick="handleLessonClick"
250 + />
251 +
252 + <!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview -->
253 +
254 + <!-- Office 文档预览弹窗 -->
255 + <!--<van-popup v-model:show="officeShow" position="center" round closeable :style="{ height: '80%', width: '90%' }">
179 <div class="h-full flex flex-col"> 256 <div class="h-full flex flex-col">
180 <div class="p-4 border-b border-gray-200"> 257 <div class="p-4 border-b border-gray-200">
181 <h3 class="text-lg font-medium text-center truncate">{{ officeTitle }}</h3> 258 <h3 class="text-lg font-medium text-center truncate">{{ officeTitle }}</h3>
...@@ -194,88 +271,126 @@ ...@@ -194,88 +271,126 @@
194 </div> 271 </div>
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
199 - <div class="p-4"> 276 + v-model:show="audioShow"
200 - <h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3> 277 + position="bottom"
201 - <AudioPlayer v-if="audioShow && audioUrl" :songs="[{ title: audioTitle, url: audioUrl }]" 278 + round
202 - class="w-full" /> 279 + closeable
203 - </div> 280 + :style="{ height: '60%', width: '100%' }"
204 - </van-popup> 281 + >
205 - 282 + <div class="p-4">
206 - <!-- 视频播放器弹窗 --> 283 + <h3 class="mb-4 text-center text-lg font-medium">{{ audioTitle }}</h3>
207 - <van-popup v-model:show="videoShow" position="center" round closeable 284 + <AudioPlayer
208 - :style="{ width: '95%', maxHeight: '80vh' }" @close="stopPopupVideoPlay"> 285 + v-if="audioShow && audioUrl"
209 - <div class="p-4"> 286 + :songs="[{ title: audioTitle, url: audioUrl }]"
210 - <h3 class="text-lg font-medium mb-4 text-center">视频预览</h3> 287 + class="w-full"
211 - <div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;"> 288 + />
212 - <!-- 视频封面 --> 289 + </div>
213 - <div v-show="!isPopupVideoPlaying" 290 + </van-popup>
214 - class="absolute inset-0 bg-black flex items-center justify-center cursor-pointer" 291 +
215 - @click="startPopupVideoPlay"> 292 + <!-- 视频播放器弹窗 -->
216 - <div class="w-16 h-16 bg-white bg-opacity-80 rounded-full flex items-center justify-center"> 293 + <van-popup
217 - <svg class="w-8 h-8 text-black ml-1" fill="currentColor" viewBox="0 0 24 24"> 294 + v-model:show="videoShow"
218 - <path d="M8 5v14l11-7z" /> 295 + position="center"
219 - </svg> 296 + round
220 - </div> 297 + closeable
221 - </div> 298 + :style="{ width: '95%', maxHeight: '80vh' }"
222 - <!-- 视频播放器 --> 299 + @close="stopPopupVideoPlay"
223 - <VideoPlayer v-show="isPopupVideoPlaying" ref="popupVideoPlayerRef" :video-url="videoUrl" 300 + >
224 - :video-id="videoTitle" :autoplay="false" :use-native-on-ios="false" class="w-full h-full" @play="handlePopupVideoPlay" 301 + <div class="p-4">
225 - @pause="handlePopupVideoPause" /> 302 + <h3 class="mb-4 text-center text-lg font-medium">视频预览</h3>
226 - </div> 303 + <div class="relative w-full overflow-hidden rounded-lg bg-black" style="aspect-ratio: 16/9">
304 + <!-- 视频封面 -->
305 + <div
306 + v-show="!isPopupVideoPlaying"
307 + class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black"
308 + @click="startPopupVideoPlay"
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">
314 + <path d="M8 5v14l11-7z" />
315 + </svg>
227 </div> 316 </div>
228 - </van-popup> 317 + </div>
229 - 318 + <!-- 视频播放器 -->
230 - <!-- 打卡弹窗 --> 319 + <VideoPlayer
231 - <CheckInDialog v-model:show="showCheckInDialog" :items_today="task_list" :items_history="timeout_task_list" 320 + v-show="isPopupVideoPlaying"
232 - @check-in-success="handleCheckInSuccess" /> 321 + ref="popupVideoPlayerRef"
233 - 322 + :video-url="videoUrl"
234 - <!-- 学习资源弹窗 --> 323 + :video-id="videoTitle"
235 - <StudyMaterialsPopup v-model:showMaterialsPopup="showMaterialsPopup" :files="courseFile?.list || []" 324 + :autoplay="false"
236 - @openPdf="showPdf" @openAudio="showAudio" @openVideo="showVideo" @openImage="showImage" /> 325 + :use-native-on-ios="false"
237 - </div> 326 + :debug="true"
327 + class="h-full w-full"
328 + @play="handlePopupVideoPlay"
329 + @pause="handlePopupVideoPause"
330 + />
331 + </div>
332 + </div>
333 + </van-popup>
334 +
335 + <!-- 打卡弹窗 -->
336 + <CheckInDialog
337 + v-model:show="showCheckInDialog"
338 + :items_today="task_list"
339 + :items_history="timeout_task_list"
340 + @check-in-success="handleCheckInSuccess"
341 + />
342 +
343 + <!-- 学习资源弹窗 -->
344 + <StudyMaterialsPopup
345 + v-model:showMaterialsPopup="showMaterialsPopup"
346 + :files="courseFile?.list || []"
347 + @openPdf="showPdf"
348 + @openAudio="showAudio"
349 + @openVideo="showVideo"
350 + @openImage="showImage"
351 + />
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,
266 - commentList, 381 + commentList,
267 - newComment, 382 + newComment,
268 - showCommentPopup, 383 + showCommentPopup,
269 - popupComment, 384 + popupComment,
270 - popupCommentList, 385 + popupCommentList,
271 - popupLoading, 386 + popupLoading,
272 - popupFinished, 387 + popupFinished,
273 - refreshComments, 388 + refreshComments,
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,106 +398,126 @@ const { ...@@ -283,106 +398,126 @@ 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
290 - commentList.value = (commentList.value || []).filter(item => String(item?.id) !== id) 405 + commentList.value = (commentList.value || []).filter(item => String(item?.id) !== id)
291 - popupCommentList.value = (popupCommentList.value || []).filter(item => String(item?.id) !== id) 406 + popupCommentList.value = (popupCommentList.value || []).filter(item => String(item?.id) !== 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(
323 - if (videoPlayerRef.value) { 438 + 'StudyDetailPage: typeof videoPlayerRef.value?.play:',
324 - videoPlayerRef.value.play(); 439 + typeof videoPlayerRef.value?.play
325 - } else { 440 + )
326 - console.error('StudyDetailPage: videoPlayerRef.value 不存在'); 441 + if (videoPlayerRef.value) {
327 - } 442 + console.log('StudyDetailPage: 调用 play() 方法...')
328 - }, 300); // 增加延迟,确保播放器初始化完成 443 + const playResult = videoPlayerRef.value.play()
329 -}; 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 + }
462 + } else {
463 + console.error('StudyDetailPage: videoPlayerRef.value 不存在')
464 + }
465 + }, 300) // 增加延迟,确保播放器初始化完成
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
...@@ -390,42 +525,42 @@ const closeImagePreview = () => { ...@@ -390,42 +525,42 @@ const closeImagePreview = () => {
390 * @returns {void} 525 * @returns {void}
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,122 +570,125 @@ const courseFile = ref({}); ...@@ -435,122 +570,125 @@ 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) {
487 - audioList.value = data.file.list.map(item => ({ 622 + audioList.value = data.file.list.map(item => ({
488 - meta_id: item.meta_id, 623 + meta_id: item.meta_id,
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 - }
521 } 655 }
522 -}; 656 + }
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({
547 - // 新增记录 682 + name: 'PdfPreview',
548 - let paramsObj = { 683 + query: { url: encodedUrl, title: encodedTitle, returnId: courseId.value, openMaterials: '1' },
549 - schedule_id: courseId.value, 684 + })
550 - meta_id 685 + // 新增记录
551 - } 686 + const paramsObj = {
552 - addRecord(paramsObj); 687 + schedule_id: courseId.value,
553 -}; 688 + meta_id,
689 + }
690 + addRecord(paramsObj)
691 +}
554 692
555 /** 693 /**
556 * 显示 Office 文档预览 694 * 显示 Office 文档预览
...@@ -597,228 +735,246 @@ const showPdf = ({ title, url, meta_id }) => { ...@@ -597,228 +735,246 @@ 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 () => {
707 - // 延迟设置topWrapper和bottomWrapper的高度 857 + // 预加载 Video.js 资源,避免首次播放时黑屏
708 - setTimeout(() => { 858 + preloadVideoJs()
709 - nextTick(() => { 859 +
710 - const topWrapper = document.querySelector('.top-wrapper'); 860 + // 延迟设置topWrapper和bottomWrapper的高度
711 - const bottomWrapper = document.querySelector('.bottom-wrapper'); 861 + setTimeout(() => {
712 - if (topWrapper) { 862 + nextTick(() => {
713 - topWrapperHeight.value = topWrapper.clientHeight + 'px'; 863 + const topWrapper = document.querySelector('.top-wrapper')
714 - } 864 + const bottomWrapper = document.querySelector('.bottom-wrapper')
715 - if (bottomWrapper) { 865 + if (topWrapper) {
716 - bottomWrapperHeight.value = bottomWrapper.clientHeight + 'px'; 866 + topWrapperHeight.value = `${topWrapper.clientHeight}px`
717 - } 867 + }
718 - 868 + if (bottomWrapper) {
719 - // 添加滚动监听 869 + bottomWrapperHeight.value = `${bottomWrapper.clientHeight}px`
720 - window.addEventListener('scroll', handleScroll); 870 + }
871 +
872 + // 添加滚动监听
873 + window.addEventListener('scroll', handleScroll)
874 + })
875 + }, 500)
876 +
877 + if (courseId.value) {
878 + const { code, data } = await getScheduleCourseAPI({ i: courseId.value })
879 + if (code === 1) {
880 + course.value = data
881 + courseFile.value = data.file
882 + // 课程大纲细项打卡互动
883 + task_list.value = []
884 + timeout_task_list.value = []
885 +
886 + // 处理task_list数据格式
887 + task_list.value = normalizeCheckinTaskItems(course.value?.task_list)
888 +
889 + // 处理timeout_task_list数据格式
890 + timeout_task_list.value = normalizeCheckinTaskItems(course.value.data?.timeout_task_list)
891 +
892 + // 音频列表处理
893 + if (data.course_type === 'audio') {
894 + audioList.value = data.file.list
895 + data.file.list.forEach((item, index) => {
896 + item.cover = 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'
721 }) 897 })
722 - }, 500); 898 + }
723 -
724 - if (courseId.value) {
725 - const { code, data } = await getScheduleCourseAPI({ i: courseId.value });
726 - if (code === 1) {
727 - course.value = data;
728 - courseFile.value = data.file;
729 - // 课程大纲细项打卡互动
730 - task_list.value = [];
731 - timeout_task_list.value = [];
732 -
733 - // 处理task_list数据格式
734 - task_list.value = normalizeCheckinTaskItems(course.value?.task_list)
735 -
736 - // 处理timeout_task_list数据格式
737 - timeout_task_list.value = normalizeCheckinTaskItems(course.value.data?.timeout_task_list)
738 -
739 - // 音频列表处理
740 - if (data.course_type === 'audio') {
741 - audioList.value = data.file.list;
742 - data.file.list.forEach((item, index) => {
743 - item.cover = 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'
744 - })
745 - }
746 -
747 - // 刷新评论列表
748 - await refreshComments();
749 -
750 - // 获取课程信息
751 - const detail = await getCourseDetailAPI({ i: course.value.group_id });
752 - if (detail.code === 1) {
753 - // 课程目录
754 - course_lessons.value = detail.data.schedule || [];
755 - }
756 - }
757 - // 图片附件或者附件不存在
758 - // 进入后直接执行学习时长埋点
759 - if (course.value.course_type === 'image' || !course.value.course_type) {
760 - // 新增记录
761 - let paramsObj = {
762 - schedule_id: courseId.value,
763 - }
764 - addRecord(paramsObj);
765 - }
766 - }
767 -});
768 899
769 -// 处理标签页切换 900 + // 刷新评论列表
770 -const handleTabChange = (name) => { 901 + await refreshComments()
771 - // 先更新activeTab值
772 - activeTab.value = name;
773 902
774 - // 设置标记,表示这是由tab切换触发的滚动 903 + // 获取课程信息
775 - isTabScrolling.value = true; 904 + const detail = await getCourseDetailAPI({ i: course.value.group_id })
905 + if (detail.code === 1) {
906 + // 课程目录
907 + course_lessons.value = detail.data.schedule || []
908 + }
909 + }
910 + // 图片附件或者附件不存在
911 + // 进入后直接执行学习时长埋点
912 + if (course.value.course_type === 'image' || !course.value.course_type) {
913 + // 新增记录
914 + const paramsObj = {
915 + schedule_id: courseId.value,
916 + }
917 + addRecord(paramsObj)
918 + }
919 + }
920 +})
776 921
777 - // 然后执行滚动操作 922 +// 处理标签页切换
778 - nextTick(() => { 923 +const handleTabChange = name => {
779 - const element = document.getElementById(name === 'intro' ? 'intro' : 'comment'); 924 + // 先更新activeTab值
780 - if (element) { 925 + activeTab.value = name
781 - const topOffset = element.offsetTop - parseInt(topWrapperHeight.value); 926 +
782 - window.scrollTo({ 927 + // 设置标记,表示这是由tab切换触发的滚动
783 - top: topOffset, 928 + isTabScrolling.value = true
784 - behavior: 'smooth' 929 +
785 - }); 930 + // 然后执行滚动操作
786 - 931 + nextTick(() => {
787 - // 滚动动画结束后重置标记 932 + const element = document.getElementById(name === 'intro' ? 'intro' : 'comment')
788 - setTimeout(() => { 933 + if (element) {
789 - isTabScrolling.value = false; 934 + const topOffset = element.offsetTop - parseInt(topWrapperHeight.value)
790 - }, 500); // 假设滚动动画持续500ms 935 + window.scrollTo({
791 - } else { 936 + top: topOffset,
792 - isTabScrolling.value = false; 937 + behavior: 'smooth',
793 - } 938 + })
794 - }); 939 +
795 -}; 940 + // 滚动动画结束后重置标记
941 + setTimeout(() => {
942 + isTabScrolling.value = false
943 + }, 500) // 假设滚动动画持续500ms
944 + } else {
945 + isTabScrolling.value = false
946 + }
947 + })
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,95 +1050,95 @@ watch(showMaterialsPopup, (val, oldVal) => { ...@@ -895,95 +1050,95 @@ 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
948 <style lang="less" scoped> 1103 <style lang="less" scoped>
949 :deep(.van-cell.van-field) { 1104 :deep(.van-cell.van-field) {
950 - background-color: rgb(244, 244, 244); 1105 + background-color: rgb(244, 244, 244);
951 } 1106 }
952 1107
953 #pdf-container { 1108 #pdf-container {
954 - margin-top: 3rem; 1109 + margin-top: 3rem;
955 - // 高度100%-3rem 1110 + // 高度100%-3rem
956 - height: calc(100% - 3rem); 1111 + height: calc(100% - 3rem);
957 - width: 100%; 1112 + width: 100%;
958 - padding: 0px; 1113 + padding: 0px;
959 } 1114 }
960 1115
961 // 图片预览关闭按钮样式 1116 // 图片预览关闭按钮样式
962 .image-preview-close-btn { 1117 .image-preview-close-btn {
963 - position: fixed; 1118 + position: fixed;
964 - bottom: 30px; 1119 + bottom: 30px;
965 - left: 50%; 1120 + left: 50%;
966 - transform: translateX(-50%); 1121 + transform: translateX(-50%);
967 - z-index: 1000; 1122 + z-index: 1000;
968 - width: 50px; 1123 + width: 50px;
969 - height: 50px; 1124 + height: 50px;
970 - background: rgba(0, 0, 0, 0.6); 1125 + background: rgba(0, 0, 0, 0.6);
971 - border-radius: 50%; 1126 + border-radius: 50%;
972 - display: flex; 1127 + display: flex;
973 - align-items: center; 1128 + align-items: center;
974 - justify-content: center; 1129 + justify-content: center;
975 - cursor: pointer; 1130 + cursor: pointer;
976 - transition: all 0.3s ease; 1131 + transition: all 0.3s ease;
977 - backdrop-filter: blur(10px); 1132 + backdrop-filter: blur(10px);
978 - border: 1px solid rgba(255, 255, 255, 0.2); 1133 + border: 1px solid rgba(255, 255, 255, 0.2);
979 } 1134 }
980 1135
981 .image-preview-close-btn:hover { 1136 .image-preview-close-btn:hover {
982 - background: rgba(0, 0, 0, 0.8); 1137 + background: rgba(0, 0, 0, 0.8);
983 - transform: translateX(-50%) scale(1.1); 1138 + transform: translateX(-50%) scale(1.1);
984 } 1139 }
985 1140
986 .image-preview-close-btn:active { 1141 .image-preview-close-btn:active {
987 - transform: translateX(-50%) scale(0.95); 1142 + transform: translateX(-50%) scale(0.95);
988 } 1143 }
989 </style> 1144 </style>
......