hookehuyr

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

重构海报生成逻辑,使用更可靠的图片加载检测机制
移除不必要的二维码本地生成和封面转base64逻辑
添加弹窗事件监听确保生成时机准确
引入自动重合成机制避免数据变更导致的空白
...@@ -5,6 +5,8 @@ ...@@ -5,6 +5,8 @@
5 round 5 round
6 position="bottom" 6 position="bottom"
7 :style="{ width: '100%' }" 7 :style="{ width: '100%' }"
8 + @open="on_popup_open"
9 + @close="on_popup_close"
8 > 10 >
9 <div class="PosterWrapper p-4"> 11 <div class="PosterWrapper p-4">
10 <!-- 标题与关闭按钮 --> 12 <!-- 标题与关闭按钮 -->
...@@ -82,12 +84,6 @@ function normalize_image_url(src) { ...@@ -82,12 +84,6 @@ function normalize_image_url(src) {
82 try { 84 try {
83 const u = new URL(url, window.location.origin) 85 const u = new URL(url, window.location.origin)
84 if (u.hostname === 'cdn.ipadbiz.cn') { 86 if (u.hostname === 'cdn.ipadbiz.cn') {
85 - // CDN 图片统一追加压缩参数,若地址未包含 imageMogr2,则追加压缩参数
86 - // const param = 'imageMogr2/thumbnail/200x/strip/quality/70'
87 - // const has_mogr = url.includes('imageMogr2')
88 - // if (!has_mogr) {
89 - // return url + (url.includes('?') ? '&' : '?') + param
90 - // }
91 return url 87 return url
92 } 88 }
93 } catch (e) { 89 } catch (e) {
...@@ -136,6 +132,11 @@ const card_ref = ref(null) ...@@ -136,6 +132,11 @@ const card_ref = ref(null)
136 * @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。 132 * @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。
137 */ 133 */
138 const is_generating = ref(false) 134 const is_generating = ref(false)
135 +/**
136 + * @var {import('vue').Ref<boolean>} needs_recompose
137 + * @description 生成过程中如有数据变化则标记,生成结束后自动再次合成,避免首次空白。
138 + */
139 +const needs_recompose = ref(false)
139 140
140 /** 标题/副标题/介绍 */ 141 /** 标题/副标题/介绍 */
141 const title_text = computed(() => props.course?.title || '课程') 142 const title_text = computed(() => props.course?.title || '课程')
...@@ -186,7 +187,7 @@ const date_range_text = computed(() => { ...@@ -186,7 +187,7 @@ const date_range_text = computed(() => {
186 }) 187 })
187 188
188 /** 封面图地址(含CDN压缩规则) */ 189 /** 封面图地址(含CDN压缩规则) */
189 -const cover_src = computed(() => normalize_image_url(props.course?.cover || '')) 190 +const cover_src = computed(() => (props.course?.cover || ''))
190 /** 191 /**
191 * @var {import('vue').Ref<string>} cover_data_url 192 * @var {import('vue').Ref<string>} cover_data_url
192 * @description 封面图的 base64;优先用 base64 以避免跨域获取失败 193 * @description 封面图的 base64;优先用 base64 以避免跨域获取失败
...@@ -202,7 +203,7 @@ const cover_final_src = computed(() => cover_data_url.value || cover_src.value) ...@@ -202,7 +203,7 @@ const cover_final_src = computed(() => cover_data_url.value || cover_src.value)
202 * @returns {string} 可直接用于 img 的二维码服务地址 203 * @returns {string} 可直接用于 img 的二维码服务地址
203 */ 204 */
204 function build_qr_service_url(raw_url) { 205 function build_qr_service_url(raw_url) {
205 - const origin = import.meta.env.VITE_PROXY_TARGET || (typeof window !== 'undefined' ? window.location.origin : '') 206 + const origin = window.location.origin
206 // 规整 origin,确保以 / 结尾便于 URL 组合 207 // 规整 origin,确保以 / 结尾便于 URL 组合
207 const base = origin.endsWith('/') ? origin : origin + '/' 208 const base = origin.endsWith('/') ? origin : origin + '/'
208 const api = new URL('admin/?m=srv&a=get_qrcode', base) 209 const api = new URL('admin/?m=srv&a=get_qrcode', base)
...@@ -217,13 +218,9 @@ const qr_src = computed(() => { ...@@ -217,13 +218,9 @@ const qr_src = computed(() => {
217 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') 218 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
218 return build_qr_service_url(url) 219 return build_qr_service_url(url)
219 }) 220 })
220 -/** 221 +
221 - * @var {import('vue').Ref<string>} qr_data_url
222 - * @description 本地生成的二维码 base64,避免跨域图片导致生成失败
223 - */
224 -const qr_data_url = ref('')
225 /** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */ 222 /** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */
226 -const qr_final_src = computed(() => qr_data_url.value || qr_src.value) 223 +const qr_final_src = computed(() => qr_src.value)
227 224
228 // 海报图片 dataURL(用于长按保存) 225 // 海报图片 dataURL(用于长按保存)
229 const poster_img_src = ref('') 226 const poster_img_src = ref('')
...@@ -252,29 +249,6 @@ async function try_fetch_to_data_url(url) { ...@@ -252,29 +249,6 @@ async function try_fetch_to_data_url(url) {
252 } 249 }
253 250
254 /** 251 /**
255 - * @function prepare_assets
256 - * @description 在截图前准备资源:封面尝试转 base64、二维码本地生成为 base64。
257 - * @returns {Promise<void>}
258 - */
259 -async function prepare_assets() {
260 - // 二维码本地生成
261 - try {
262 - const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
263 - const data_url = await QRCode.toDataURL(url, { margin: 2, width: 256, color: { dark: '#000000', light: '#ffffff' } })
264 - qr_data_url.value = data_url || ''
265 - } catch (e) {
266 - qr_data_url.value = ''
267 - }
268 - // 封面尝试转 base64(若跨域失败则回退原地址)
269 - try {
270 - const b64 = await try_fetch_to_data_url(cover_src.value)
271 - cover_data_url.value = b64 || ''
272 - } catch (e) {
273 - cover_data_url.value = ''
274 - }
275 -}
276 -
277 -/**
278 * @function compose_poster 252 * @function compose_poster
279 * @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。 253 * @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。
280 * @returns {Promise<void>} 254 * @returns {Promise<void>}
...@@ -284,18 +258,35 @@ async function compose_poster() { ...@@ -284,18 +258,35 @@ async function compose_poster() {
284 try { 258 try {
285 // 标记进入生成流程 259 // 标记进入生成流程
286 is_generating.value = true 260 is_generating.value = true
287 - // 准备资源,尽量使用 base64 避免跨域失败
288 - await prepare_assets()
289 - await nextTick()
290 const node = card_ref.value 261 const node = card_ref.value
291 if (!node) { 262 if (!node) {
292 is_generating.value = false 263 is_generating.value = false
264 + // 生成结束后若存在队列请求,则再次合成
265 + maybe_recompose_after_finish()
293 return 266 return
294 } 267 }
295 // 等待容器内图片加载完成,避免首次截图丢失封面 268 // 等待容器内图片加载完成,避免首次截图丢失封面
296 await wait_images_loaded(node) 269 await wait_images_loaded(node)
297 - // 轻微延迟,确保弹窗过渡动画结束,布局稳定 270 + // 等待封面图片明确就绪(有 src 且 naturalWidth > 0),不足则设置一次性自动重合成
298 - await new Promise(r => setTimeout(r, 80)) 271 + const cover_ready = await ensure_cover_ready(node, 3500)
272 + if (!cover_ready) {
273 + // 监听封面 load 后自动重试一次
274 + const img = node.querySelector('.PosterCover img')
275 + if (img) {
276 + img.addEventListener('load', () => {
277 + if (show_proxy.value && !is_generating.value) {
278 + nextTick(() => compose_poster())
279 + }
280 + }, { once: true })
281 + }
282 + is_generating.value = false
283 + maybe_recompose_after_finish()
284 + return
285 + }
286 + // 等待弹窗过渡动画结束,确保布局稳定
287 + await wait_transition_end(node, 320)
288 + // 轻微延迟,确保尺寸计算稳定
289 + await new Promise(r => setTimeout(r, 60))
299 // 克隆离屏节点,避免弹窗动画与布局抖动影响截图 290 // 克隆离屏节点,避免弹窗动画与布局抖动影响截图
300 const { wrapper, clone, size } = create_offscreen_clone(node) 291 const { wrapper, clone, size } = create_offscreen_clone(node)
301 await wait_images_loaded(clone, 1200) 292 await wait_images_loaded(clone, 1200)
...@@ -320,11 +311,15 @@ async function compose_poster() { ...@@ -320,11 +311,15 @@ async function compose_poster() {
320 // 清理离屏节点 311 // 清理离屏节点
321 if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper) 312 if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper)
322 is_generating.value = false 313 is_generating.value = false
314 + // 生成结束后若存在队列请求,则再次合成
315 + maybe_recompose_after_finish()
323 } catch (err) { 316 } catch (err) {
324 console.error('html-to-image 生成海报失败:', err) 317 console.error('html-to-image 生成海报失败:', err)
325 showToast('海报生成失败,已展示标准卡片,请长按保存截图') 318 showToast('海报生成失败,已展示标准卡片,请长按保存截图')
326 poster_img_src.value = '' 319 poster_img_src.value = ''
327 is_generating.value = false 320 is_generating.value = false
321 + // 生成结束后若存在队列请求,则再次合成
322 + maybe_recompose_after_finish()
328 } 323 }
329 } 324 }
330 325
...@@ -367,17 +362,84 @@ function create_offscreen_clone(node) { ...@@ -367,17 +362,84 @@ function create_offscreen_clone(node) {
367 } 362 }
368 363
369 /** 364 /**
370 - * @function watch_open_and_generate 365 + * @function ensure_cover_ready
371 - * @description 仅在弹窗打开时开始生成海报;关闭不生成。每次打开都重新生成以保证信息最新。 366 + * @description 等待封面图片可用(存在 src 且 naturalWidth > 0)。
367 + * @param {HTMLElement} node 卡片根节点
368 + * @param {number} timeout 超时毫秒
369 + * @returns {Promise<boolean>} 是否就绪
370 + */
371 +function ensure_cover_ready(node, timeout = 3000) {
372 + return new Promise((resolve) => {
373 + try {
374 + const img = node?.querySelector?.('.PosterCover img')
375 + if (!img) return resolve(false)
376 + if (img.src && img.complete && img.naturalWidth > 0) return resolve(true)
377 + let done = false
378 + const finish = (ok) => { if (!done) { done = true; resolve(!!ok) } }
379 + const onload = () => finish(true)
380 + const onerror = () => finish(false)
381 + img.addEventListener('load', onload, { once: true })
382 + img.addEventListener('error', onerror, { once: true })
383 + // 若当前无 src,监听 src 变化
384 + if (!img.src) {
385 + const obs = new MutationObserver(() => {
386 + if (img.src && img.complete && img.naturalWidth > 0) {
387 + obs.disconnect()
388 + finish(true)
389 + }
390 + })
391 + obs.observe(img, { attributes: true, attributeFilter: ['src'] })
392 + setTimeout(() => { obs.disconnect(); finish(false) }, timeout)
393 + } else {
394 + setTimeout(() => finish(img.naturalWidth > 0), timeout)
395 + }
396 + } catch (_) {
397 + resolve(false)
398 + }
399 + })
400 +}
401 +
402 +/**
403 + * @function wait_transition_end
404 + * @description 等待弹窗或卡片相关的过渡动画结束,避免动画期间截图导致首帧空白。
405 + * @param {HTMLElement} node 卡片根节点
406 + * @param {number} timeout 超时毫秒
407 + * @returns {Promise<void>}
408 + */
409 +function wait_transition_end(node, timeout = 320) {
410 + return new Promise((resolve) => {
411 + try {
412 + let done = false
413 + const finish = () => { if (!done) { done = true; resolve() } }
414 + const targets = [node, node?.parentElement, node?.closest?.('.van-popup')]
415 + const opts = { once: true }
416 + targets.forEach(el => {
417 + if (el && el.addEventListener) {
418 + el.addEventListener('transitionend', finish, opts)
419 + el.addEventListener('animationend', finish, opts)
420 + el.addEventListener('webkitTransitionEnd', finish, opts)
421 + }
422 + })
423 + setTimeout(finish, timeout)
424 + } catch (_) {
425 + resolve()
426 + }
427 + })
428 +}
429 +
430 +/**
431 + * @function maybe_recompose_after_finish
432 + * @description 若生成期间数据发生变化(如封面地址更新),生成结束后自动再次合成。
372 * @returns {void} 433 * @returns {void}
373 */ 434 */
374 -watch(show_proxy, (opened) => { 435 +function maybe_recompose_after_finish() {
375 - if (opened) { 436 + if (needs_recompose.value && show_proxy.value) {
376 - if (is_generating.value) return 437 + needs_recompose.value = false
377 - poster_img_src.value = ''
378 nextTick(() => compose_poster()) 438 nextTick(() => compose_poster())
379 } 439 }
380 -}) 440 +}
441 +
442 +// 移除基于 show 的 watcher,改为使用 Popup 的 open 事件更精确地触发生成,避免重复调用
381 443
382 /** 444 /**
383 * @function recompose_on_data_change 445 * @function recompose_on_data_change
...@@ -414,8 +476,8 @@ async function wait_images_loaded(node, timeout = 2500) { ...@@ -414,8 +476,8 @@ async function wait_images_loaded(node, timeout = 2500) {
414 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成 476 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
415 watch([() => props.course?.cover, () => props.qr_url], () => { 477 watch([() => props.course?.cover, () => props.qr_url], () => {
416 if (show_proxy.value) { 478 if (show_proxy.value) {
417 - if (is_generating.value) return
418 poster_img_src.value = '' 479 poster_img_src.value = ''
480 + if (is_generating.value) { needs_recompose.value = true; return }
419 nextTick(() => compose_poster()) 481 nextTick(() => compose_poster())
420 } 482 }
421 }) 483 })
...@@ -456,3 +518,27 @@ watch([() => props.course?.cover, () => props.qr_url], () => { ...@@ -456,3 +518,27 @@ watch([() => props.course?.cover, () => props.qr_url], () => {
456 } 518 }
457 } 519 }
458 </style> 520 </style>
521 +/**
522 + * @function on_popup_open
523 + * @description 弹窗打开时启动海报生成,仅在打开时触发,避免未打开即生成。
524 + * @returns {void}
525 + */
526 +function on_popup_open() {
527 + try {
528 + poster_img_src.value = ''
529 + if (is_generating.value) { needs_recompose.value = true; return }
530 + nextTick(() => compose_poster())
531 + } catch (_) {}
532 +}
533 +
534 +/**
535 + * @function on_popup_close
536 + * @description 弹窗关闭时进行轻量清理,避免关闭后继续生成或重试。
537 + * @returns {void}
538 + */
539 +function on_popup_close() {
540 + try {
541 + // 关闭后不再自动重合成
542 + needs_recompose.value = false
543 + } catch (_) {}
544 +}
......