refactor(SharePoster): 优化海报生成流程并移除冗余代码
重构海报生成逻辑,使用更可靠的图片加载检测机制 移除不必要的二维码本地生成和封面转base64逻辑 添加弹窗事件监听确保生成时机准确 引入自动重合成机制避免数据变更导致的空白
Showing
1 changed file
with
136 additions
and
50 deletions
| ... | @@ -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 | +} | ... | ... |
-
Please register or login to post a comment