refactor(SharePoster): 优化海报生成逻辑并移除冗余代码
重构海报生成流程,使用本地生成二维码和封面图base64避免跨域问题 移除不必要的状态管理和事件监听,简化代码结构 调整样式细节和注释位置
Showing
1 changed file
with
50 additions
and
140 deletions
| ... | @@ -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 | -} | ... | ... |
-
Please register or login to post a comment