hookehuyr

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

- 添加隐藏的预渲染节点,避免动态计算容器宽度导致的封面空白
- 在iOS微信环境中使用html2canvas替代html-to-image以解决兼容性问题
- 优化封面图片加载逻辑,添加原图兜底策略
- 调整样式和监听逻辑,确保数据变化时能正确触发重新生成
......@@ -24,7 +24,7 @@
<div v-else>
<!-- 上部封面图 -->
<div class="PosterCover rounded-t-xl overflow-hidden">
<img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" data-role="cover" />
<img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" data-role="cover" loading="eager" />
</div>
<!-- 下部信息区:左二维码 + 右文案 -->
<div class="PosterInfo p-4">
......@@ -34,11 +34,11 @@
<img :src="qr_final_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" />
</div>
<!-- 右侧文案 -->
<div class="flex-1 flex flex-col space-y-2 -mt-1">
<div class="text-lg font-semibold text-gray-800 truncate leading-tight -mt-0.5">{{ title_text }}</div>
<div class="text-sm text-gray-500 truncate" v-if="subtitle_text">{{ subtitle_text }}</div>
<div class="text-sm text-gray-400 truncate" v-if="date_range_text">{{ date_range_text }}</div>
<div class="mt-2 text-green-600 text-sm">扫码了解详情</div>
<div class="flex-1 flex flex-col space-y-1">
<div class="text-lg font-semibold text-gray-800 leading-normal">{{ title_text }}</div>
<div class="text-sm text-gray-500 leading-normal" v-if="subtitle_text">{{ subtitle_text }}</div>
<div class="text-sm text-gray-400 leading-normal" v-if="date_range_text">{{ date_range_text }}</div>
<div class="mt-2 text-green-600 text-sm leading-normal">扫码了解详情</div>
</div>
</div>
</div>
......@@ -52,12 +52,37 @@
</div>
</div>
</van-popup>
<div class="fixed inset-0 opacity-[0.01] pointer-events-none -z-10">
<div class="PosterWrapper p-4 w-screen">
<div class="PosterCard bg-white rounded-xl overflow-hidden border border-gray-200 mx-auto" ref="preload_ref">
<div>
<div class="PosterCover rounded-t-xl overflow-hidden">
<img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" data-role="cover" loading="eager" />
</div>
<div class="PosterInfo p-4">
<div class="flex items-start">
<div class="PosterQR mr-4">
<img :src="qr_final_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" />
</div>
<div class="flex-1 flex flex-col space-y-1">
<div class="text-lg font-semibold text-gray-800 leading-normal">{{ title_text }}</div>
<div class="text-sm text-gray-500 leading-normal" v-if="subtitle_text">{{ subtitle_text }}</div>
<div class="text-sm text-gray-400 leading-normal" v-if="date_range_text">{{ date_range_text }}</div>
<div class="mt-2 text-green-600 text-sm leading-normal">扫码了解详情</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<van-back-top right="5vw" bottom="25vh" offset="600" />
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { toPng } from 'html-to-image'
import html2canvas from 'html2canvas'
import QRCode from 'qrcode'
import { showToast } from 'vant'
import { MIN_TIMEOUT, DEFAULT_FETCH_TIMEOUT } from '@/common/constants'
......@@ -158,6 +183,7 @@ const show_proxy = computed({
* @description 海报卡片容器引用,用于动态获取容器宽度以计算 Canvas 尺寸
*/
const card_ref = ref(null)
const preload_ref = ref(null)
/**
* @var {import('vue').Ref<boolean>} is_generating
......@@ -302,7 +328,7 @@ async function prepare_assets() {
try {
const raw = props.course?.cover || ''
// 依据容器宽度与像素比计算目标宽度,设置上限避免过大导致内存/渲染失败
const base_w = Math.max(320, Math.round((card_ref.value?.clientWidth || 750)))
const base_w = Math.max(320, Math.round((preload_ref.value?.clientWidth || card_ref.value?.clientWidth || 750)))
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
const target_w = Math.min(900, Math.round(base_w * dpr))
const big_url = build_cdn_thumbnail(raw, target_w)
......@@ -315,6 +341,11 @@ async function prepare_assets() {
const b64_small = await fetch_to_data_url_with_timeout(small_url, 2000)
cover_data_url.value = b64_small || ''
}
// 封面兜底:使用原图地址再尝试一次转 base64,减少移动端空白
if (!cover_data_url.value && raw) {
const b64_origin = await fetch_to_data_url_with_timeout(raw, 3500)
cover_data_url.value = b64_origin || ''
}
} catch (e) {
cover_data_url.value = ''
}
......@@ -326,14 +357,13 @@ async function prepare_assets() {
* @returns {Promise<void>}
*/
async function compose_poster() {
poster_img_src.value = ''
try {
// 标记进入生成流程
is_generating.value = true
// 准备资源,尽量使用 base64 避免跨域失败
await prepare_assets()
await nextTick()
const node = card_ref.value
const node = preload_ref.value || card_ref.value
if (!node) {
is_generating.value = false
return
......@@ -349,25 +379,39 @@ async function compose_poster() {
const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1))
// 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断
const placeholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wm3T9kAAAAASUVORK5CYII='
// 生成 PNG dataURL
const data_url = await toPng(clone, {
pixelRatio: pixel_ratio,
cacheBust: true,
imagePlaceholder: placeholder,
fetchRequestInit: { mode: 'cors', cache: 'no-cache', credentials: 'omit' },
// 避免外层 margin 影响截图结果
style: { margin: '0' },
width: Math.round(size.width),
height: Math.round(size.height)
})
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
const is_ios_wechat = /MicroMessenger/i.test(ua) && /iPhone|iPad|iPod/i.test(ua)
let data_url = ''
if (is_ios_wechat) {
const canvas = await html2canvas(clone, {
scale: pixel_ratio,
useCORS: true,
allowTaint: false,
backgroundColor: null,
width: Math.round(size.width),
height: Math.round(size.height)
})
data_url = canvas.toDataURL('image/png')
} else {
data_url = await toPng(clone, {
pixelRatio: pixel_ratio,
cacheBust: true,
imagePlaceholder: placeholder,
fetchRequestInit: { mode: 'cors', cache: 'no-cache', credentials: 'omit' },
// 避免外层 margin 影响截图结果
style: { margin: '0' },
width: Math.round(size.width),
height: Math.round(size.height)
})
}
poster_img_src.value = data_url
has_generated_once.value = true
// 清理离屏节点
if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper)
is_generating.value = false
} catch (err) {
console.error('html-to-image 生成海报失败:', err)
showToast('海报生成失败,已展示标准卡片,请长按保存截图')
poster_img_src.value = ''
is_generating.value = false
}
}
......@@ -379,15 +423,7 @@ async function compose_poster() {
*/
async function generate_on_open() {
try {
if (!has_generated_once.value) {
await compose_poster()
// 短暂等待,确保图片与布局稳定后再次生成,规避首次封面空白
await new Promise(r => setTimeout(r, 120))
await compose_poster()
has_generated_once.value = true
} else {
await compose_poster()
}
await compose_poster()
} catch (_) {
// 忽略异常,compose_poster 已包含错误提示
}
......@@ -401,7 +437,8 @@ async function generate_on_open() {
*/
function create_offscreen_clone(node) {
const rect = node.getBoundingClientRect()
const size = { width: rect.width, height: rect.height }
const extra = 12
const size = { width: rect.width, height: rect.height + extra }
const wrapper = document.createElement('div')
const clone = node.cloneNode(true)
// 离屏包裹容器样式
......@@ -422,6 +459,7 @@ function create_offscreen_clone(node) {
width: '100%',
height: '100%',
margin: '0',
paddingBottom: `${extra}px`,
transform: 'none',
filter: 'none',
boxShadow: 'none',
......@@ -440,8 +478,8 @@ function create_offscreen_clone(node) {
*/
watch(show_proxy, (opened) => {
if (opened) {
if (poster_img_src.value) return
if (is_generating.value) return
poster_img_src.value = ''
nextTick(() => generate_on_open())
}
})
......@@ -506,13 +544,26 @@ async function fetch_to_data_url_with_timeout(url, timeout_ms) {
}
// 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
watch([() => props.course?.cover, () => props.qr_url], () => {
if (show_proxy.value) {
if (is_generating.value) return
poster_img_src.value = ''
nextTick(() => compose_poster())
}
const poster_source_list = computed(() => [
props.course?.cover || '',
props.course?.title || '',
props.course?.subtitle || '',
props.course?.course_start_time || props.course?.start_at || '',
props.course?.course_end_time || props.course?.end_at || '',
props.qr_url || ''
])
const is_poster_ready = computed(() => {
const title_value = String(props.course?.title || '').trim()
const cover_value = String(props.course?.cover || '').trim()
return Boolean(title_value || cover_value)
})
watch(poster_source_list, () => {
if (!is_poster_ready.value) return
if (is_generating.value) return
nextTick(() => compose_poster())
}, { immediate: true })
</script>
<style lang="less" scoped>
......@@ -540,13 +591,14 @@ watch([() => props.course?.cover, () => props.qr_url], () => {
overflow: hidden;
}
.PosterInfo {
overflow: hidden;
overflow: visible;
padding-bottom: 8px;
.PosterQR {
img {
box-shadow: none;
img {
box-shadow: none;
}
}
}
}
}
}
</style>
......