feat(SharePoster): 优化封面图加载逻辑并添加数据属性
添加封面图的 data-role 属性便于追踪 实现 CDN 图片压缩参数构建功能 优化封面图加载策略,优先尝试大图后降级小图 增加带超时的图片预取功能,避免长时间等待 调整图片加载等待时间,提升截图稳定性
Showing
1 changed file
with
73 additions
and
6 deletions
| ... | @@ -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) { | ... | ... |
-
Please register or login to post a comment