hookehuyr

feat(SharePoster): 优化封面图加载逻辑并添加数据属性

添加封面图的 data-role 属性便于追踪
实现 CDN 图片压缩参数构建功能
优化封面图加载策略,优先尝试大图后降级小图
增加带超时的图片预取功能,避免长时间等待
调整图片加载等待时间,提升截图稳定性
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
24 <div v-else> 24 <div v-else>
25 <!-- 上部封面图 --> 25 <!-- 上部封面图 -->
26 <div class="PosterCover rounded-t-xl overflow-hidden"> 26 <div class="PosterCover rounded-t-xl overflow-hidden">
27 - <img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" /> 27 + <img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" data-role="cover" />
28 </div> 28 </div>
29 <!-- 下部信息区:左二维码 + 右文案 --> 29 <!-- 下部信息区:左二维码 + 右文案 -->
30 <div class="PosterInfo p-4"> 30 <div class="PosterInfo p-4">
...@@ -96,6 +96,33 @@ function normalize_image_url(src) { ...@@ -96,6 +96,33 @@ function normalize_image_url(src) {
96 return url 96 return url
97 } 97 }
98 98
99 +/**
100 + * @function build_cdn_thumbnail
101 + * @description 为 CDN 图片构造指定宽度的压缩参数;非 CDN 原样返回。
102 + * @param {string} src 原始图片地址
103 + * @param {number} width 目标宽度(像素)
104 + * @returns {string} 处理后的图片地址
105 + */
106 +function build_cdn_thumbnail(src, width) {
107 + const url = src || ''
108 + if (!url) return ''
109 + try {
110 + const u = new URL(url, window.location.origin)
111 + if (u.hostname === 'cdn.ipadbiz.cn') {
112 + const param = `imageMogr2/thumbnail/${Math.max(100, Math.round(width))}x/strip/quality/70`
113 + const has_mogr = url.includes('imageMogr2')
114 + // 若已有 imageMogr2,直接返回原地址,避免重复追加造成不确定行为
115 + if (!has_mogr) {
116 + return url + (url.includes('?') ? '&' : '?') + param
117 + }
118 + return url
119 + }
120 + } catch (e) {
121 + // URL 无法解析则直接返回原值
122 + }
123 + return url
124 +}
125 +
99 const props = defineProps({ 126 const props = defineProps({
100 /** 弹窗显隐(v-model:show) */ 127 /** 弹窗显隐(v-model:show) */
101 show: { type: Boolean, default: false }, 128 show: { type: Boolean, default: false },
...@@ -265,10 +292,23 @@ async function prepare_assets() { ...@@ -265,10 +292,23 @@ async function prepare_assets() {
265 } catch (e) { 292 } catch (e) {
266 qr_data_url.value = '' 293 qr_data_url.value = ''
267 } 294 }
268 - // 封面尝试转 base64(若跨域失败则回退原地址) 295 + // 封面尝试转 base64(先大图,超时/失败降级为小图;若跨域失败则回退原地址)
269 try { 296 try {
270 - const b64 = await try_fetch_to_data_url(cover_src.value) 297 + const raw = props.course?.cover || ''
271 - cover_data_url.value = b64 || '' 298 + // 依据容器宽度与像素比计算目标宽度,设置上限避免过大导致内存/渲染失败
299 + const base_w = Math.max(320, Math.round((card_ref.value?.clientWidth || 750)))
300 + const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
301 + const target_w = Math.min(900, Math.round(base_w * dpr))
302 + const big_url = build_cdn_thumbnail(raw, target_w)
303 + const small_url = build_cdn_thumbnail(raw, 200)
304 + // 大图优先,设置较短超时,失败则使用小图保证不空白
305 + const b64_big = await fetch_to_data_url_with_timeout(big_url, 2500)
306 + if (b64_big) {
307 + cover_data_url.value = b64_big
308 + } else {
309 + const b64_small = await fetch_to_data_url_with_timeout(small_url, 2000)
310 + cover_data_url.value = b64_small || ''
311 + }
272 } catch (e) { 312 } catch (e) {
273 cover_data_url.value = '' 313 cover_data_url.value = ''
274 } 314 }
...@@ -293,12 +333,12 @@ async function compose_poster() { ...@@ -293,12 +333,12 @@ async function compose_poster() {
293 return 333 return
294 } 334 }
295 // 等待容器内图片加载完成,避免首次截图丢失封面 335 // 等待容器内图片加载完成,避免首次截图丢失封面
296 - await wait_images_loaded(node) 336 + await wait_images_loaded(node, 4000)
297 // 轻微延迟,确保弹窗过渡动画结束,布局稳定 337 // 轻微延迟,确保弹窗过渡动画结束,布局稳定
298 await new Promise(r => setTimeout(r, 80)) 338 await new Promise(r => setTimeout(r, 80))
299 // 克隆离屏节点,避免弹窗动画与布局抖动影响截图 339 // 克隆离屏节点,避免弹窗动画与布局抖动影响截图
300 const { wrapper, clone, size } = create_offscreen_clone(node) 340 const { wrapper, clone, size } = create_offscreen_clone(node)
301 - await wait_images_loaded(clone, 1200) 341 + await wait_images_loaded(clone, 1600)
302 // 设置像素比例,提升清晰度(在高分屏上更清晰) 342 // 设置像素比例,提升清晰度(在高分屏上更清晰)
303 const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1)) 343 const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1))
304 // 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断 344 // 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断
...@@ -410,6 +450,33 @@ async function wait_images_loaded(node, timeout = 2500) { ...@@ -410,6 +450,33 @@ async function wait_images_loaded(node, timeout = 2500) {
410 } 450 }
411 } 451 }
412 452
453 +/**
454 + * @function fetch_to_data_url_with_timeout
455 + * @description 带超时的资源预取并转为 base64,避免大图加载过慢导致首屏空白。
456 + * @param {string} url 资源地址
457 + * @param {number} timeout_ms 超时时间毫秒
458 + * @returns {Promise<string>} 成功返回 dataURL,失败或超时返回空字符串
459 + */
460 +async function fetch_to_data_url_with_timeout(url, timeout_ms) {
461 + if (!url) return ''
462 + try {
463 + const ctrl = new AbortController()
464 + const timer = setTimeout(() => ctrl.abort(), Math.max(800, timeout_ms || 2000))
465 + const resp = await fetch(url, { mode: 'cors', cache: 'no-cache', credentials: 'omit', signal: ctrl.signal })
466 + clearTimeout(timer)
467 + if (!resp.ok) return ''
468 + const blob = await resp.blob()
469 + return await new Promise((resolve) => {
470 + const reader = new FileReader()
471 + reader.onloadend = () => resolve(reader.result || '')
472 + reader.onerror = () => resolve('')
473 + reader.readAsDataURL(blob)
474 + })
475 + } catch (_) {
476 + return ''
477 + }
478 +}
479 +
413 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成 480 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
414 watch([() => props.course?.cover, () => props.qr_url], () => { 481 watch([() => props.course?.cover, () => props.qr_url], () => {
415 if (show_proxy.value) { 482 if (show_proxy.value) {
......