hookehuyr

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

重构海报生成流程,使用本地生成二维码和封面图base64避免跨域问题
移除不必要的状态管理和事件监听,简化代码结构
调整样式细节和注释位置
...@@ -5,8 +5,6 @@ ...@@ -5,8 +5,6 @@
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"
10 > 8 >
11 <div class="PosterWrapper p-4"> 9 <div class="PosterWrapper p-4">
12 <!-- 标题与关闭按钮 --> 10 <!-- 标题与关闭按钮 -->
...@@ -16,7 +14,7 @@ ...@@ -16,7 +14,7 @@
16 </div> 14 </div>
17 15
18 <!-- 生成中提示 --> 16 <!-- 生成中提示 -->
19 - <div v-if="is_generating" class="text-center text-gray-500 text-sm mb-2">正在生成海报...</div> 17 + <!-- <div v-if="is_generating" class="text-center text-gray-500 text-sm mb-2">正在生成海报...</div> -->
20 18
21 <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 --> 19 <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 -->
22 <!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 --> 20 <!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 -->
...@@ -39,10 +37,10 @@ ...@@ -39,10 +37,10 @@
39 <div class="flex-1 flex flex-col space-y-2 -mt-1"> 37 <div class="flex-1 flex flex-col space-y-2 -mt-1">
40 <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 truncate leading-tight -mt-0.5">{{ title_text }}</div>
41 <div class="text-sm text-gray-500 truncate" v-if="subtitle_text">{{ subtitle_text }}</div> 39 <div class="text-sm text-gray-500 truncate" v-if="subtitle_text">{{ subtitle_text }}</div>
42 - <div class="text-lg text-gray-400 truncate" v-if="date_range_text">{{ date_range_text }}</div> 40 + <div class="text-sm text-gray-400 truncate" v-if="date_range_text">{{ date_range_text }}</div>
41 + <div class="mt-2 text-green-600 text-sm">扫码了解详情</div>
43 </div> 42 </div>
44 </div> 43 </div>
45 - <div class="mt-4 text-green-600 text-base">扫码了解详情</div>
46 </div> 44 </div>
47 </div> 45 </div>
48 </div> 46 </div>
...@@ -84,7 +82,9 @@ function normalize_image_url(src) { ...@@ -84,7 +82,9 @@ function normalize_image_url(src) {
84 try { 82 try {
85 const u = new URL(url, window.location.origin) 83 const u = new URL(url, window.location.origin)
86 if (u.hostname === 'cdn.ipadbiz.cn') { 84 if (u.hostname === 'cdn.ipadbiz.cn') {
87 - return url 85 + if (u.hostname === 'cdn.ipadbiz.cn') {
86 + return url + '?imageMogr2/thumbnail/400x/strip/quality/70'
87 + }
88 } 88 }
89 } catch (e) { 89 } catch (e) {
90 // 非绝对路径或无法解析的场景,直接返回原值 90 // 非绝对路径或无法解析的场景,直接返回原值
...@@ -132,11 +132,6 @@ const card_ref = ref(null) ...@@ -132,11 +132,6 @@ const card_ref = ref(null)
132 * @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。 132 * @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。
133 */ 133 */
134 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)
140 135
141 /** 标题/副标题/介绍 */ 136 /** 标题/副标题/介绍 */
142 const title_text = computed(() => props.course?.title || '课程') 137 const title_text = computed(() => props.course?.title || '课程')
...@@ -187,7 +182,7 @@ const date_range_text = computed(() => { ...@@ -187,7 +182,7 @@ const date_range_text = computed(() => {
187 }) 182 })
188 183
189 /** 封面图地址(含CDN压缩规则) */ 184 /** 封面图地址(含CDN压缩规则) */
190 -const cover_src = computed(() => (props.course?.cover || '')) 185 +const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))
191 /** 186 /**
192 * @var {import('vue').Ref<string>} cover_data_url 187 * @var {import('vue').Ref<string>} cover_data_url
193 * @description 封面图的 base64;优先用 base64 以避免跨域获取失败 188 * @description 封面图的 base64;优先用 base64 以避免跨域获取失败
...@@ -203,7 +198,7 @@ const cover_final_src = computed(() => cover_data_url.value || cover_src.value) ...@@ -203,7 +198,7 @@ const cover_final_src = computed(() => cover_data_url.value || cover_src.value)
203 * @returns {string} 可直接用于 img 的二维码服务地址 198 * @returns {string} 可直接用于 img 的二维码服务地址
204 */ 199 */
205 function build_qr_service_url(raw_url) { 200 function build_qr_service_url(raw_url) {
206 - const origin = window.location.origin 201 + const origin = import.meta.env.VITE_PROXY_TARGET || (typeof window !== 'undefined' ? window.location.origin : '')
207 // 规整 origin,确保以 / 结尾便于 URL 组合 202 // 规整 origin,确保以 / 结尾便于 URL 组合
208 const base = origin.endsWith('/') ? origin : origin + '/' 203 const base = origin.endsWith('/') ? origin : origin + '/'
209 const api = new URL('admin/?m=srv&a=get_qrcode', base) 204 const api = new URL('admin/?m=srv&a=get_qrcode', base)
...@@ -218,9 +213,13 @@ const qr_src = computed(() => { ...@@ -218,9 +213,13 @@ const qr_src = computed(() => {
218 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') 213 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
219 return build_qr_service_url(url) 214 return build_qr_service_url(url)
220 }) 215 })
221 - 216 +/**
217 + * @var {import('vue').Ref<string>} qr_data_url
218 + * @description 本地生成的二维码 base64,避免跨域图片导致生成失败
219 + */
220 +const qr_data_url = ref('')
222 /** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */ 221 /** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */
223 -const qr_final_src = computed(() => qr_src.value) 222 +const qr_final_src = computed(() => qr_data_url.value || qr_src.value)
224 223
225 // 海报图片 dataURL(用于长按保存) 224 // 海报图片 dataURL(用于长按保存)
226 const poster_img_src = ref('') 225 const poster_img_src = ref('')
...@@ -249,6 +248,29 @@ async function try_fetch_to_data_url(url) { ...@@ -249,6 +248,29 @@ async function try_fetch_to_data_url(url) {
249 } 248 }
250 249
251 /** 250 /**
251 + * @function prepare_assets
252 + * @description 在截图前准备资源:封面尝试转 base64、二维码本地生成为 base64。
253 + * @returns {Promise<void>}
254 + */
255 +async function prepare_assets() {
256 + // 二维码本地生成
257 + try {
258 + const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
259 + const data_url = await QRCode.toDataURL(url, { margin: 2, width: 256, color: { dark: '#000000', light: '#ffffff' } })
260 + qr_data_url.value = data_url || ''
261 + } catch (e) {
262 + qr_data_url.value = ''
263 + }
264 + // 封面尝试转 base64(若跨域失败则回退原地址)
265 + try {
266 + const b64 = await try_fetch_to_data_url(cover_src.value)
267 + cover_data_url.value = b64 || ''
268 + } catch (e) {
269 + cover_data_url.value = ''
270 + }
271 +}
272 +
273 +/**
252 * @function compose_poster 274 * @function compose_poster
253 * @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。 275 * @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。
254 * @returns {Promise<void>} 276 * @returns {Promise<void>}
...@@ -258,35 +280,18 @@ async function compose_poster() { ...@@ -258,35 +280,18 @@ async function compose_poster() {
258 try { 280 try {
259 // 标记进入生成流程 281 // 标记进入生成流程
260 is_generating.value = true 282 is_generating.value = true
283 + // 准备资源,尽量使用 base64 避免跨域失败
284 + await prepare_assets()
285 + await nextTick()
261 const node = card_ref.value 286 const node = card_ref.value
262 if (!node) { 287 if (!node) {
263 is_generating.value = false 288 is_generating.value = false
264 - // 生成结束后若存在队列请求,则再次合成
265 - maybe_recompose_after_finish()
266 return 289 return
267 } 290 }
268 // 等待容器内图片加载完成,避免首次截图丢失封面 291 // 等待容器内图片加载完成,避免首次截图丢失封面
269 await wait_images_loaded(node) 292 await wait_images_loaded(node)
270 - // 等待封面图片明确就绪(有 src 且 naturalWidth > 0),不足则设置一次性自动重合成 293 + // 轻微延迟,确保弹窗过渡动画结束,布局稳定
271 - const cover_ready = await ensure_cover_ready(node, 3500) 294 + await new Promise(r => setTimeout(r, 80))
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))
290 // 克隆离屏节点,避免弹窗动画与布局抖动影响截图 295 // 克隆离屏节点,避免弹窗动画与布局抖动影响截图
291 const { wrapper, clone, size } = create_offscreen_clone(node) 296 const { wrapper, clone, size } = create_offscreen_clone(node)
292 await wait_images_loaded(clone, 1200) 297 await wait_images_loaded(clone, 1200)
...@@ -311,15 +316,11 @@ async function compose_poster() { ...@@ -311,15 +316,11 @@ async function compose_poster() {
311 // 清理离屏节点 316 // 清理离屏节点
312 if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper) 317 if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper)
313 is_generating.value = false 318 is_generating.value = false
314 - // 生成结束后若存在队列请求,则再次合成
315 - maybe_recompose_after_finish()
316 } catch (err) { 319 } catch (err) {
317 console.error('html-to-image 生成海报失败:', err) 320 console.error('html-to-image 生成海报失败:', err)
318 showToast('海报生成失败,已展示标准卡片,请长按保存截图') 321 showToast('海报生成失败,已展示标准卡片,请长按保存截图')
319 poster_img_src.value = '' 322 poster_img_src.value = ''
320 is_generating.value = false 323 is_generating.value = false
321 - // 生成结束后若存在队列请求,则再次合成
322 - maybe_recompose_after_finish()
323 } 324 }
324 } 325 }
325 326
...@@ -362,84 +363,17 @@ function create_offscreen_clone(node) { ...@@ -362,84 +363,17 @@ function create_offscreen_clone(node) {
362 } 363 }
363 364
364 /** 365 /**
365 - * @function ensure_cover_ready 366 + * @function watch_open_and_generate
366 - * @description 等待封面图片可用(存在 src 且 naturalWidth > 0)。 367 + * @description 仅在弹窗打开时开始生成海报;关闭不生成。每次打开都重新生成以保证信息最新。
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 若生成期间数据发生变化(如封面地址更新),生成结束后自动再次合成。
433 * @returns {void} 368 * @returns {void}
434 */ 369 */
435 -function maybe_recompose_after_finish() { 370 +watch(show_proxy, (opened) => {
436 - if (needs_recompose.value && show_proxy.value) { 371 + if (opened) {
437 - needs_recompose.value = false 372 + if (is_generating.value) return
373 + poster_img_src.value = ''
438 nextTick(() => compose_poster()) 374 nextTick(() => compose_poster())
439 } 375 }
440 -} 376 +})
441 -
442 -// 移除基于 show 的 watcher,改为使用 Popup 的 open 事件更精确地触发生成,避免重复调用
443 377
444 /** 378 /**
445 * @function recompose_on_data_change 379 * @function recompose_on_data_change
...@@ -476,8 +410,8 @@ async function wait_images_loaded(node, timeout = 2500) { ...@@ -476,8 +410,8 @@ async function wait_images_loaded(node, timeout = 2500) {
476 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成 410 // 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
477 watch([() => props.course?.cover, () => props.qr_url], () => { 411 watch([() => props.course?.cover, () => props.qr_url], () => {
478 if (show_proxy.value) { 412 if (show_proxy.value) {
413 + if (is_generating.value) return
479 poster_img_src.value = '' 414 poster_img_src.value = ''
480 - if (is_generating.value) { needs_recompose.value = true; return }
481 nextTick(() => compose_poster()) 415 nextTick(() => compose_poster())
482 } 416 }
483 }) 417 })
...@@ -518,27 +452,3 @@ watch([() => props.course?.cover, () => props.qr_url], () => { ...@@ -518,27 +452,3 @@ watch([() => props.course?.cover, () => props.qr_url], () => {
518 } 452 }
519 } 453 }
520 </style> 454 </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 -}
......