hookehuyr

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

添加封面图的 data-role 属性便于追踪
实现 CDN 图片压缩参数构建功能
优化封面图加载策略,优先尝试大图后降级小图
增加带超时的图片预取功能,避免长时间等待
调整图片加载等待时间,提升截图稳定性
......@@ -24,7 +24,7 @@
<div v-else>
<!-- 上部封面图 -->
<div class="PosterCover rounded-t-xl overflow-hidden">
<img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" />
<img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" data-role="cover" />
</div>
<!-- 下部信息区:左二维码 + 右文案 -->
<div class="PosterInfo p-4">
......@@ -96,6 +96,33 @@ function normalize_image_url(src) {
return url
}
/**
* @function build_cdn_thumbnail
* @description 为 CDN 图片构造指定宽度的压缩参数;非 CDN 原样返回。
* @param {string} src 原始图片地址
* @param {number} width 目标宽度(像素)
* @returns {string} 处理后的图片地址
*/
function build_cdn_thumbnail(src, width) {
const url = src || ''
if (!url) return ''
try {
const u = new URL(url, window.location.origin)
if (u.hostname === 'cdn.ipadbiz.cn') {
const param = `imageMogr2/thumbnail/${Math.max(100, Math.round(width))}x/strip/quality/70`
const has_mogr = url.includes('imageMogr2')
// 若已有 imageMogr2,直接返回原地址,避免重复追加造成不确定行为
if (!has_mogr) {
return url + (url.includes('?') ? '&' : '?') + param
}
return url
}
} catch (e) {
// URL 无法解析则直接返回原值
}
return url
}
const props = defineProps({
/** 弹窗显隐(v-model:show) */
show: { type: Boolean, default: false },
......@@ -265,10 +292,23 @@ async function prepare_assets() {
} catch (e) {
qr_data_url.value = ''
}
// 封面尝试转 base64(若跨域失败则回退原地址)
// 封面尝试转 base64(先大图,超时/失败降级为小图;若跨域失败则回退原地址)
try {
const b64 = await try_fetch_to_data_url(cover_src.value)
cover_data_url.value = b64 || ''
const raw = props.course?.cover || ''
// 依据容器宽度与像素比计算目标宽度,设置上限避免过大导致内存/渲染失败
const base_w = Math.max(320, Math.round((card_ref.value?.clientWidth || 750)))
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
const target_w = Math.min(900, Math.round(base_w * dpr))
const big_url = build_cdn_thumbnail(raw, target_w)
const small_url = build_cdn_thumbnail(raw, 200)
// 大图优先,设置较短超时,失败则使用小图保证不空白
const b64_big = await fetch_to_data_url_with_timeout(big_url, 2500)
if (b64_big) {
cover_data_url.value = b64_big
} else {
const b64_small = await fetch_to_data_url_with_timeout(small_url, 2000)
cover_data_url.value = b64_small || ''
}
} catch (e) {
cover_data_url.value = ''
}
......@@ -293,12 +333,12 @@ async function compose_poster() {
return
}
// 等待容器内图片加载完成,避免首次截图丢失封面
await wait_images_loaded(node)
await wait_images_loaded(node, 4000)
// 轻微延迟,确保弹窗过渡动画结束,布局稳定
await new Promise(r => setTimeout(r, 80))
// 克隆离屏节点,避免弹窗动画与布局抖动影响截图
const { wrapper, clone, size } = create_offscreen_clone(node)
await wait_images_loaded(clone, 1200)
await wait_images_loaded(clone, 1600)
// 设置像素比例,提升清晰度(在高分屏上更清晰)
const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1))
// 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断
......@@ -410,6 +450,33 @@ async function wait_images_loaded(node, timeout = 2500) {
}
}
/**
* @function fetch_to_data_url_with_timeout
* @description 带超时的资源预取并转为 base64,避免大图加载过慢导致首屏空白。
* @param {string} url 资源地址
* @param {number} timeout_ms 超时时间毫秒
* @returns {Promise<string>} 成功返回 dataURL,失败或超时返回空字符串
*/
async function fetch_to_data_url_with_timeout(url, timeout_ms) {
if (!url) return ''
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), Math.max(800, timeout_ms || 2000))
const resp = await fetch(url, { mode: 'cors', cache: 'no-cache', credentials: 'omit', signal: ctrl.signal })
clearTimeout(timer)
if (!resp.ok) return ''
const blob = await resp.blob()
return await new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result || '')
reader.onerror = () => resolve('')
reader.readAsDataURL(blob)
})
} catch (_) {
return ''
}
}
// 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
watch([() => props.course?.cover, () => props.qr_url], () => {
if (show_proxy.value) {
......