hookehuyr

refactor(SharePoster): 优化海报生成流程并移除冗余代码

重构海报生成逻辑,使用更可靠的图片加载检测机制
移除不必要的二维码本地生成和封面转base64逻辑
添加弹窗事件监听确保生成时机准确
引入自动重合成机制避免数据变更导致的空白
......@@ -5,6 +5,8 @@
round
position="bottom"
:style="{ width: '100%' }"
@open="on_popup_open"
@close="on_popup_close"
>
<div class="PosterWrapper p-4">
<!-- 标题与关闭按钮 -->
......@@ -82,12 +84,6 @@ function normalize_image_url(src) {
try {
const u = new URL(url, window.location.origin)
if (u.hostname === 'cdn.ipadbiz.cn') {
// CDN 图片统一追加压缩参数,若地址未包含 imageMogr2,则追加压缩参数
// const param = 'imageMogr2/thumbnail/200x/strip/quality/70'
// const has_mogr = url.includes('imageMogr2')
// if (!has_mogr) {
// return url + (url.includes('?') ? '&' : '?') + param
// }
return url
}
} catch (e) {
......@@ -136,6 +132,11 @@ const card_ref = ref(null)
* @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。
*/
const is_generating = ref(false)
/**
* @var {import('vue').Ref<boolean>} needs_recompose
* @description 生成过程中如有数据变化则标记,生成结束后自动再次合成,避免首次空白。
*/
const needs_recompose = ref(false)
/** 标题/副标题/介绍 */
const title_text = computed(() => props.course?.title || '课程')
......@@ -186,7 +187,7 @@ const date_range_text = computed(() => {
})
/** 封面图地址(含CDN压缩规则) */
const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))
const cover_src = computed(() => (props.course?.cover || ''))
/**
* @var {import('vue').Ref<string>} cover_data_url
* @description 封面图的 base64;优先用 base64 以避免跨域获取失败
......@@ -202,7 +203,7 @@ const cover_final_src = computed(() => cover_data_url.value || cover_src.value)
* @returns {string} 可直接用于 img 的二维码服务地址
*/
function build_qr_service_url(raw_url) {
const origin = import.meta.env.VITE_PROXY_TARGET || (typeof window !== 'undefined' ? window.location.origin : '')
const origin = window.location.origin
// 规整 origin,确保以 / 结尾便于 URL 组合
const base = origin.endsWith('/') ? origin : origin + '/'
const api = new URL('admin/?m=srv&a=get_qrcode', base)
......@@ -217,13 +218,9 @@ const qr_src = computed(() => {
const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
return build_qr_service_url(url)
})
/**
* @var {import('vue').Ref<string>} qr_data_url
* @description 本地生成的二维码 base64,避免跨域图片导致生成失败
*/
const qr_data_url = ref('')
/** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */
const qr_final_src = computed(() => qr_data_url.value || qr_src.value)
const qr_final_src = computed(() => qr_src.value)
// 海报图片 dataURL(用于长按保存)
const poster_img_src = ref('')
......@@ -252,29 +249,6 @@ async function try_fetch_to_data_url(url) {
}
/**
* @function prepare_assets
* @description 在截图前准备资源:封面尝试转 base64、二维码本地生成为 base64。
* @returns {Promise<void>}
*/
async function prepare_assets() {
// 二维码本地生成
try {
const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
const data_url = await QRCode.toDataURL(url, { margin: 2, width: 256, color: { dark: '#000000', light: '#ffffff' } })
qr_data_url.value = data_url || ''
} catch (e) {
qr_data_url.value = ''
}
// 封面尝试转 base64(若跨域失败则回退原地址)
try {
const b64 = await try_fetch_to_data_url(cover_src.value)
cover_data_url.value = b64 || ''
} catch (e) {
cover_data_url.value = ''
}
}
/**
* @function compose_poster
* @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。
* @returns {Promise<void>}
......@@ -284,18 +258,35 @@ async function compose_poster() {
try {
// 标记进入生成流程
is_generating.value = true
// 准备资源,尽量使用 base64 避免跨域失败
await prepare_assets()
await nextTick()
const node = card_ref.value
if (!node) {
is_generating.value = false
// 生成结束后若存在队列请求,则再次合成
maybe_recompose_after_finish()
return
}
// 等待容器内图片加载完成,避免首次截图丢失封面
await wait_images_loaded(node)
// 轻微延迟,确保弹窗过渡动画结束,布局稳定
await new Promise(r => setTimeout(r, 80))
// 等待封面图片明确就绪(有 src 且 naturalWidth > 0),不足则设置一次性自动重合成
const cover_ready = await ensure_cover_ready(node, 3500)
if (!cover_ready) {
// 监听封面 load 后自动重试一次
const img = node.querySelector('.PosterCover img')
if (img) {
img.addEventListener('load', () => {
if (show_proxy.value && !is_generating.value) {
nextTick(() => compose_poster())
}
}, { once: true })
}
is_generating.value = false
maybe_recompose_after_finish()
return
}
// 等待弹窗过渡动画结束,确保布局稳定
await wait_transition_end(node, 320)
// 轻微延迟,确保尺寸计算稳定
await new Promise(r => setTimeout(r, 60))
// 克隆离屏节点,避免弹窗动画与布局抖动影响截图
const { wrapper, clone, size } = create_offscreen_clone(node)
await wait_images_loaded(clone, 1200)
......@@ -320,11 +311,15 @@ async function compose_poster() {
// 清理离屏节点
if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper)
is_generating.value = false
// 生成结束后若存在队列请求,则再次合成
maybe_recompose_after_finish()
} catch (err) {
console.error('html-to-image 生成海报失败:', err)
showToast('海报生成失败,已展示标准卡片,请长按保存截图')
poster_img_src.value = ''
is_generating.value = false
// 生成结束后若存在队列请求,则再次合成
maybe_recompose_after_finish()
}
}
......@@ -367,17 +362,84 @@ function create_offscreen_clone(node) {
}
/**
* @function watch_open_and_generate
* @description 仅在弹窗打开时开始生成海报;关闭不生成。每次打开都重新生成以保证信息最新。
* @function ensure_cover_ready
* @description 等待封面图片可用(存在 src 且 naturalWidth > 0)。
* @param {HTMLElement} node 卡片根节点
* @param {number} timeout 超时毫秒
* @returns {Promise<boolean>} 是否就绪
*/
function ensure_cover_ready(node, timeout = 3000) {
return new Promise((resolve) => {
try {
const img = node?.querySelector?.('.PosterCover img')
if (!img) return resolve(false)
if (img.src && img.complete && img.naturalWidth > 0) return resolve(true)
let done = false
const finish = (ok) => { if (!done) { done = true; resolve(!!ok) } }
const onload = () => finish(true)
const onerror = () => finish(false)
img.addEventListener('load', onload, { once: true })
img.addEventListener('error', onerror, { once: true })
// 若当前无 src,监听 src 变化
if (!img.src) {
const obs = new MutationObserver(() => {
if (img.src && img.complete && img.naturalWidth > 0) {
obs.disconnect()
finish(true)
}
})
obs.observe(img, { attributes: true, attributeFilter: ['src'] })
setTimeout(() => { obs.disconnect(); finish(false) }, timeout)
} else {
setTimeout(() => finish(img.naturalWidth > 0), timeout)
}
} catch (_) {
resolve(false)
}
})
}
/**
* @function wait_transition_end
* @description 等待弹窗或卡片相关的过渡动画结束,避免动画期间截图导致首帧空白。
* @param {HTMLElement} node 卡片根节点
* @param {number} timeout 超时毫秒
* @returns {Promise<void>}
*/
function wait_transition_end(node, timeout = 320) {
return new Promise((resolve) => {
try {
let done = false
const finish = () => { if (!done) { done = true; resolve() } }
const targets = [node, node?.parentElement, node?.closest?.('.van-popup')]
const opts = { once: true }
targets.forEach(el => {
if (el && el.addEventListener) {
el.addEventListener('transitionend', finish, opts)
el.addEventListener('animationend', finish, opts)
el.addEventListener('webkitTransitionEnd', finish, opts)
}
})
setTimeout(finish, timeout)
} catch (_) {
resolve()
}
})
}
/**
* @function maybe_recompose_after_finish
* @description 若生成期间数据发生变化(如封面地址更新),生成结束后自动再次合成。
* @returns {void}
*/
watch(show_proxy, (opened) => {
if (opened) {
if (is_generating.value) return
poster_img_src.value = ''
function maybe_recompose_after_finish() {
if (needs_recompose.value && show_proxy.value) {
needs_recompose.value = false
nextTick(() => compose_poster())
}
})
}
// 移除基于 show 的 watcher,改为使用 Popup 的 open 事件更精确地触发生成,避免重复调用
/**
* @function recompose_on_data_change
......@@ -414,8 +476,8 @@ async function wait_images_loaded(node, timeout = 2500) {
// 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
watch([() => props.course?.cover, () => props.qr_url], () => {
if (show_proxy.value) {
if (is_generating.value) return
poster_img_src.value = ''
if (is_generating.value) { needs_recompose.value = true; return }
nextTick(() => compose_poster())
}
})
......@@ -456,3 +518,27 @@ watch([() => props.course?.cover, () => props.qr_url], () => {
}
}
</style>
/**
* @function on_popup_open
* @description 弹窗打开时启动海报生成,仅在打开时触发,避免未打开即生成。
* @returns {void}
*/
function on_popup_open() {
try {
poster_img_src.value = ''
if (is_generating.value) { needs_recompose.value = true; return }
nextTick(() => compose_poster())
} catch (_) {}
}
/**
* @function on_popup_close
* @description 弹窗关闭时进行轻量清理,避免关闭后继续生成或重试。
* @returns {void}
*/
function on_popup_close() {
try {
// 关闭后不再自动重合成
needs_recompose.value = false
} catch (_) {}
}
......