hookehuyr

fix(海报生成): 修复iOS微信中海报生成失败和封面空白问题

- 添加隐藏的预渲染节点,避免动态计算容器宽度导致的封面空白
- 在iOS微信环境中使用html2canvas替代html-to-image以解决兼容性问题
- 优化封面图片加载逻辑,添加原图兜底策略
- 调整样式和监听逻辑,确保数据变化时能正确触发重新生成
...@@ -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" data-role="cover" /> 27 + <img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" data-role="cover" loading="eager" />
28 </div> 28 </div>
29 <!-- 下部信息区:左二维码 + 右文案 --> 29 <!-- 下部信息区:左二维码 + 右文案 -->
30 <div class="PosterInfo p-4"> 30 <div class="PosterInfo p-4">
...@@ -34,11 +34,11 @@ ...@@ -34,11 +34,11 @@
34 <img :src="qr_final_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" /> 34 <img :src="qr_final_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" />
35 </div> 35 </div>
36 <!-- 右侧文案 --> 36 <!-- 右侧文案 -->
37 - <div class="flex-1 flex flex-col space-y-2 -mt-1"> 37 + <div class="flex-1 flex flex-col space-y-1">
38 - <div class="text-lg font-semibold text-gray-800 truncate leading-tight -mt-0.5">{{ title_text }}</div> 38 + <div class="text-lg font-semibold text-gray-800 leading-normal">{{ title_text }}</div>
39 - <div class="text-sm text-gray-500 truncate" v-if="subtitle_text">{{ subtitle_text }}</div> 39 + <div class="text-sm text-gray-500 leading-normal" v-if="subtitle_text">{{ subtitle_text }}</div>
40 - <div class="text-sm text-gray-400 truncate" v-if="date_range_text">{{ date_range_text }}</div> 40 + <div class="text-sm text-gray-400 leading-normal" v-if="date_range_text">{{ date_range_text }}</div>
41 - <div class="mt-2 text-green-600 text-sm">扫码了解详情</div> 41 + <div class="mt-2 text-green-600 text-sm leading-normal">扫码了解详情</div>
42 </div> 42 </div>
43 </div> 43 </div>
44 </div> 44 </div>
...@@ -52,12 +52,37 @@ ...@@ -52,12 +52,37 @@
52 </div> 52 </div>
53 </div> 53 </div>
54 </van-popup> 54 </van-popup>
55 + <div class="fixed inset-0 opacity-[0.01] pointer-events-none -z-10">
56 + <div class="PosterWrapper p-4 w-screen">
57 + <div class="PosterCard bg-white rounded-xl overflow-hidden border border-gray-200 mx-auto" ref="preload_ref">
58 + <div>
59 + <div class="PosterCover rounded-t-xl overflow-hidden">
60 + <img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" data-role="cover" loading="eager" />
61 + </div>
62 + <div class="PosterInfo p-4">
63 + <div class="flex items-start">
64 + <div class="PosterQR mr-4">
65 + <img :src="qr_final_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" />
66 + </div>
67 + <div class="flex-1 flex flex-col space-y-1">
68 + <div class="text-lg font-semibold text-gray-800 leading-normal">{{ title_text }}</div>
69 + <div class="text-sm text-gray-500 leading-normal" v-if="subtitle_text">{{ subtitle_text }}</div>
70 + <div class="text-sm text-gray-400 leading-normal" v-if="date_range_text">{{ date_range_text }}</div>
71 + <div class="mt-2 text-green-600 text-sm leading-normal">扫码了解详情</div>
72 + </div>
73 + </div>
74 + </div>
75 + </div>
76 + </div>
77 + </div>
78 + </div>
55 <van-back-top right="5vw" bottom="25vh" offset="600" /> 79 <van-back-top right="5vw" bottom="25vh" offset="600" />
56 </template> 80 </template>
57 81
58 <script setup> 82 <script setup>
59 import { ref, computed, watch, nextTick } from 'vue' 83 import { ref, computed, watch, nextTick } from 'vue'
60 import { toPng } from 'html-to-image' 84 import { toPng } from 'html-to-image'
85 +import html2canvas from 'html2canvas'
61 import QRCode from 'qrcode' 86 import QRCode from 'qrcode'
62 import { showToast } from 'vant' 87 import { showToast } from 'vant'
63 import { MIN_TIMEOUT, DEFAULT_FETCH_TIMEOUT } from '@/common/constants' 88 import { MIN_TIMEOUT, DEFAULT_FETCH_TIMEOUT } from '@/common/constants'
...@@ -158,6 +183,7 @@ const show_proxy = computed({ ...@@ -158,6 +183,7 @@ const show_proxy = computed({
158 * @description 海报卡片容器引用,用于动态获取容器宽度以计算 Canvas 尺寸 183 * @description 海报卡片容器引用,用于动态获取容器宽度以计算 Canvas 尺寸
159 */ 184 */
160 const card_ref = ref(null) 185 const card_ref = ref(null)
186 +const preload_ref = ref(null)
161 187
162 /** 188 /**
163 * @var {import('vue').Ref<boolean>} is_generating 189 * @var {import('vue').Ref<boolean>} is_generating
...@@ -302,7 +328,7 @@ async function prepare_assets() { ...@@ -302,7 +328,7 @@ async function prepare_assets() {
302 try { 328 try {
303 const raw = props.course?.cover || '' 329 const raw = props.course?.cover || ''
304 // 依据容器宽度与像素比计算目标宽度,设置上限避免过大导致内存/渲染失败 330 // 依据容器宽度与像素比计算目标宽度,设置上限避免过大导致内存/渲染失败
305 - const base_w = Math.max(320, Math.round((card_ref.value?.clientWidth || 750))) 331 + const base_w = Math.max(320, Math.round((preload_ref.value?.clientWidth || card_ref.value?.clientWidth || 750)))
306 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)) 332 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
307 const target_w = Math.min(900, Math.round(base_w * dpr)) 333 const target_w = Math.min(900, Math.round(base_w * dpr))
308 const big_url = build_cdn_thumbnail(raw, target_w) 334 const big_url = build_cdn_thumbnail(raw, target_w)
...@@ -315,6 +341,11 @@ async function prepare_assets() { ...@@ -315,6 +341,11 @@ async function prepare_assets() {
315 const b64_small = await fetch_to_data_url_with_timeout(small_url, 2000) 341 const b64_small = await fetch_to_data_url_with_timeout(small_url, 2000)
316 cover_data_url.value = b64_small || '' 342 cover_data_url.value = b64_small || ''
317 } 343 }
344 + // 封面兜底:使用原图地址再尝试一次转 base64,减少移动端空白
345 + if (!cover_data_url.value && raw) {
346 + const b64_origin = await fetch_to_data_url_with_timeout(raw, 3500)
347 + cover_data_url.value = b64_origin || ''
348 + }
318 } catch (e) { 349 } catch (e) {
319 cover_data_url.value = '' 350 cover_data_url.value = ''
320 } 351 }
...@@ -326,14 +357,13 @@ async function prepare_assets() { ...@@ -326,14 +357,13 @@ async function prepare_assets() {
326 * @returns {Promise<void>} 357 * @returns {Promise<void>}
327 */ 358 */
328 async function compose_poster() { 359 async function compose_poster() {
329 - poster_img_src.value = ''
330 try { 360 try {
331 // 标记进入生成流程 361 // 标记进入生成流程
332 is_generating.value = true 362 is_generating.value = true
333 // 准备资源,尽量使用 base64 避免跨域失败 363 // 准备资源,尽量使用 base64 避免跨域失败
334 await prepare_assets() 364 await prepare_assets()
335 await nextTick() 365 await nextTick()
336 - const node = card_ref.value 366 + const node = preload_ref.value || card_ref.value
337 if (!node) { 367 if (!node) {
338 is_generating.value = false 368 is_generating.value = false
339 return 369 return
...@@ -349,25 +379,39 @@ async function compose_poster() { ...@@ -349,25 +379,39 @@ async function compose_poster() {
349 const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1)) 379 const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1))
350 // 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断 380 // 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断
351 const placeholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wm3T9kAAAAASUVORK5CYII=' 381 const placeholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wm3T9kAAAAASUVORK5CYII='
352 - // 生成 PNG dataURL 382 + const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
353 - const data_url = await toPng(clone, { 383 + const is_ios_wechat = /MicroMessenger/i.test(ua) && /iPhone|iPad|iPod/i.test(ua)
354 - pixelRatio: pixel_ratio, 384 + let data_url = ''
355 - cacheBust: true, 385 + if (is_ios_wechat) {
356 - imagePlaceholder: placeholder, 386 + const canvas = await html2canvas(clone, {
357 - fetchRequestInit: { mode: 'cors', cache: 'no-cache', credentials: 'omit' }, 387 + scale: pixel_ratio,
358 - // 避免外层 margin 影响截图结果 388 + useCORS: true,
359 - style: { margin: '0' }, 389 + allowTaint: false,
360 - width: Math.round(size.width), 390 + backgroundColor: null,
361 - height: Math.round(size.height) 391 + width: Math.round(size.width),
362 - }) 392 + height: Math.round(size.height)
393 + })
394 + data_url = canvas.toDataURL('image/png')
395 + } else {
396 + data_url = await toPng(clone, {
397 + pixelRatio: pixel_ratio,
398 + cacheBust: true,
399 + imagePlaceholder: placeholder,
400 + fetchRequestInit: { mode: 'cors', cache: 'no-cache', credentials: 'omit' },
401 + // 避免外层 margin 影响截图结果
402 + style: { margin: '0' },
403 + width: Math.round(size.width),
404 + height: Math.round(size.height)
405 + })
406 + }
363 poster_img_src.value = data_url 407 poster_img_src.value = data_url
408 + has_generated_once.value = true
364 // 清理离屏节点 409 // 清理离屏节点
365 if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper) 410 if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper)
366 is_generating.value = false 411 is_generating.value = false
367 } catch (err) { 412 } catch (err) {
368 console.error('html-to-image 生成海报失败:', err) 413 console.error('html-to-image 生成海报失败:', err)
369 showToast('海报生成失败,已展示标准卡片,请长按保存截图') 414 showToast('海报生成失败,已展示标准卡片,请长按保存截图')
370 - poster_img_src.value = ''
371 is_generating.value = false 415 is_generating.value = false
372 } 416 }
373 } 417 }
...@@ -379,15 +423,7 @@ async function compose_poster() { ...@@ -379,15 +423,7 @@ async function compose_poster() {
379 */ 423 */
380 async function generate_on_open() { 424 async function generate_on_open() {
381 try { 425 try {
382 - if (!has_generated_once.value) { 426 + await compose_poster()
383 - await compose_poster()
384 - // 短暂等待,确保图片与布局稳定后再次生成,规避首次封面空白
385 - await new Promise(r => setTimeout(r, 120))
386 - await compose_poster()
387 - has_generated_once.value = true
388 - } else {
389 - await compose_poster()
390 - }
391 } catch (_) { 427 } catch (_) {
392 // 忽略异常,compose_poster 已包含错误提示 428 // 忽略异常,compose_poster 已包含错误提示
393 } 429 }
...@@ -401,7 +437,8 @@ async function generate_on_open() { ...@@ -401,7 +437,8 @@ async function generate_on_open() {
401 */ 437 */
402 function create_offscreen_clone(node) { 438 function create_offscreen_clone(node) {
403 const rect = node.getBoundingClientRect() 439 const rect = node.getBoundingClientRect()
404 - const size = { width: rect.width, height: rect.height } 440 + const extra = 12
441 + const size = { width: rect.width, height: rect.height + extra }
405 const wrapper = document.createElement('div') 442 const wrapper = document.createElement('div')
406 const clone = node.cloneNode(true) 443 const clone = node.cloneNode(true)
407 // 离屏包裹容器样式 444 // 离屏包裹容器样式
...@@ -422,6 +459,7 @@ function create_offscreen_clone(node) { ...@@ -422,6 +459,7 @@ function create_offscreen_clone(node) {
422 width: '100%', 459 width: '100%',
423 height: '100%', 460 height: '100%',
424 margin: '0', 461 margin: '0',
462 + paddingBottom: `${extra}px`,
425 transform: 'none', 463 transform: 'none',
426 filter: 'none', 464 filter: 'none',
427 boxShadow: 'none', 465 boxShadow: 'none',
...@@ -440,8 +478,8 @@ function create_offscreen_clone(node) { ...@@ -440,8 +478,8 @@ function create_offscreen_clone(node) {
440 */ 478 */
441 watch(show_proxy, (opened) => { 479 watch(show_proxy, (opened) => {
442 if (opened) { 480 if (opened) {
481 + if (poster_img_src.value) return
443 if (is_generating.value) return 482 if (is_generating.value) return
444 - poster_img_src.value = ''
445 nextTick(() => generate_on_open()) 483 nextTick(() => generate_on_open())
446 } 484 }
447 }) 485 })
...@@ -506,13 +544,26 @@ async function fetch_to_data_url_with_timeout(url, timeout_ms) { ...@@ -506,13 +544,26 @@ async function fetch_to_data_url_with_timeout(url, timeout_ms) {
506 } 544 }
507 545
508 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成 546 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
509 -watch([() => props.course?.cover, () => props.qr_url], () => { 547 +const poster_source_list = computed(() => [
510 - if (show_proxy.value) { 548 + props.course?.cover || '',
511 - if (is_generating.value) return 549 + props.course?.title || '',
512 - poster_img_src.value = '' 550 + props.course?.subtitle || '',
513 - nextTick(() => compose_poster()) 551 + props.course?.course_start_time || props.course?.start_at || '',
514 - } 552 + props.course?.course_end_time || props.course?.end_at || '',
553 + props.qr_url || ''
554 +])
555 +
556 +const is_poster_ready = computed(() => {
557 + const title_value = String(props.course?.title || '').trim()
558 + const cover_value = String(props.course?.cover || '').trim()
559 + return Boolean(title_value || cover_value)
515 }) 560 })
561 +
562 +watch(poster_source_list, () => {
563 + if (!is_poster_ready.value) return
564 + if (is_generating.value) return
565 + nextTick(() => compose_poster())
566 +}, { immediate: true })
516 </script> 567 </script>
517 568
518 <style lang="less" scoped> 569 <style lang="less" scoped>
...@@ -540,13 +591,14 @@ watch([() => props.course?.cover, () => props.qr_url], () => { ...@@ -540,13 +591,14 @@ watch([() => props.course?.cover, () => props.qr_url], () => {
540 overflow: hidden; 591 overflow: hidden;
541 } 592 }
542 .PosterInfo { 593 .PosterInfo {
543 - overflow: hidden; 594 + overflow: visible;
595 + padding-bottom: 8px;
544 .PosterQR { 596 .PosterQR {
545 - img { 597 + img {
546 - box-shadow: none; 598 + box-shadow: none;
599 + }
547 } 600 }
548 } 601 }
549 } 602 }
550 } 603 }
551 -}
552 </style> 604 </style>
......