fix(海报生成): 修复iOS微信中海报生成失败和封面空白问题
- 添加隐藏的预渲染节点,避免动态计算容器宽度导致的封面空白 - 在iOS微信环境中使用html2canvas替代html-to-image以解决兼容性问题 - 优化封面图片加载逻辑,添加原图兜底策略 - 调整样式和监听逻辑,确保数据变化时能正确触发重新生成
Showing
1 changed file
with
94 additions
and
42 deletions
| ... | @@ -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> | ... | ... |
-
Please register or login to post a comment