hookehuyr

feat(SharePoster): 使用html-to-image生成海报并优化资源加载

重构海报生成逻辑,使用html-to-image替代canvas手动绘制,提升生成稳定性
添加生成中状态提示,优先使用base64资源避免跨域问题
优化封面和二维码加载逻辑,移除冗余canvas代码
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
34 "@vue-office/pptx": "^1.0.1", 34 "@vue-office/pptx": "^1.0.1",
35 "browser-md5-file": "^1.1.1", 35 "browser-md5-file": "^1.1.1",
36 "dayjs": "^1.11.13", 36 "dayjs": "^1.11.13",
37 + "html-to-image": "^1.11.13",
37 "html2canvas": "^1.4.1", 38 "html2canvas": "^1.4.1",
38 "lodash": "^4.17.21", 39 "lodash": "^4.17.21",
39 "pdf-vue3": "^1.0.12", 40 "pdf-vue3": "^1.0.12",
......
...@@ -47,6 +47,9 @@ importers: ...@@ -47,6 +47,9 @@ importers:
47 dayjs: 47 dayjs:
48 specifier: ^1.11.13 48 specifier: ^1.11.13
49 version: 1.11.19 49 version: 1.11.19
50 + html-to-image:
51 + specifier: ^1.11.13
52 + version: 1.11.13
50 html2canvas: 53 html2canvas:
51 specifier: ^1.4.1 54 specifier: ^1.4.1
52 version: 1.4.1 55 version: 1.4.1
...@@ -1167,6 +1170,9 @@ packages: ...@@ -1167,6 +1170,9 @@ packages:
1167 resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} 1170 resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
1168 engines: {node: '>= 0.4'} 1171 engines: {node: '>= 0.4'}
1169 1172
1173 + html-to-image@1.11.13:
1174 + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
1175 +
1170 html2canvas@1.4.1: 1176 html2canvas@1.4.1:
1171 resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} 1177 resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
1172 engines: {node: '>=8.0.0'} 1178 engines: {node: '>=8.0.0'}
...@@ -3036,6 +3042,8 @@ snapshots: ...@@ -3036,6 +3042,8 @@ snapshots:
3036 dependencies: 3042 dependencies:
3037 function-bind: 1.1.2 3043 function-bind: 1.1.2
3038 3044
3045 + html-to-image@1.11.13: {}
3046 +
3039 html2canvas@1.4.1: 3047 html2canvas@1.4.1:
3040 dependencies: 3048 dependencies:
3041 css-line-break: 2.1.0 3049 css-line-break: 2.1.0
......
...@@ -13,22 +13,25 @@ ...@@ -13,22 +13,25 @@
13 <van-icon name="cross" @click="close" /> 13 <van-icon name="cross" @click="close" />
14 </div> 14 </div>
15 15
16 + <!-- 生成中提示 -->
17 + <div v-if="is_generating" class="text-center text-gray-500 text-sm mb-2">正在生成海报...</div>
18 +
16 <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 --> 19 <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 -->
17 <!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 --> 20 <!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 -->
18 - <div :class="poster_img_src ? 'PosterCard mx-auto' : 'PosterCard bg-white rounded-xl shadow-md overflow-hidden mx-auto'" ref="card_ref"> 21 + <div :class="poster_img_src ? 'PosterCard mx-auto' : 'PosterCard bg-white rounded-xl overflow-hidden border border-gray-200 mx-auto'" ref="card_ref">
19 <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" /> 22 <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" />
20 <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) --> 23 <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) -->
21 <div v-else> 24 <div v-else>
22 <!-- 上部封面图 --> 25 <!-- 上部封面图 -->
23 <div class="PosterCover rounded-t-xl overflow-hidden"> 26 <div class="PosterCover rounded-t-xl overflow-hidden">
24 - <img :src="cover_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" /> 27 + <img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" />
25 </div> 28 </div>
26 <!-- 下部信息区:左二维码 + 右文案 --> 29 <!-- 下部信息区:左二维码 + 右文案 -->
27 <div class="PosterInfo p-4"> 30 <div class="PosterInfo p-4">
28 <div class="flex items-start"> 31 <div class="flex items-start">
29 <!-- 左侧二维码 --> 32 <!-- 左侧二维码 -->
30 <div class="PosterQR mr-4"> 33 <div class="PosterQR mr-4">
31 - <img :src="qr_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" />
32 </div> 35 </div>
33 <!-- 右侧文案 --> 36 <!-- 右侧文案 -->
34 <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">
...@@ -53,7 +56,8 @@ ...@@ -53,7 +56,8 @@
53 </template> 56 </template>
54 57
55 <script setup> 58 <script setup>
56 -import { ref, computed, watch, nextTick, onMounted } from 'vue' 59 +import { ref, computed, watch, nextTick } from 'vue'
60 +import { toPng } from 'html-to-image'
57 import QRCode from 'qrcode' 61 import QRCode from 'qrcode'
58 import { showToast } from 'vant' 62 import { showToast } from 'vant'
59 63
...@@ -78,11 +82,12 @@ function normalize_image_url(src) { ...@@ -78,11 +82,12 @@ function normalize_image_url(src) {
78 try { 82 try {
79 const u = new URL(url, window.location.origin) 83 const u = new URL(url, window.location.origin)
80 if (u.hostname === 'cdn.ipadbiz.cn') { 84 if (u.hostname === 'cdn.ipadbiz.cn') {
81 - // CDN 图片统一追加压缩参数,若已存在查询参数则使用 & 连接 85 + // CDN 图片统一追加压缩参数,若地址未包含 imageMogr2,则追加压缩参数
82 - // const has_mogr = url.includes('imageMogr2') 86 + const param = 'imageMogr2/thumbnail/200x/strip/quality/70'
83 - // if (!has_mogr) { 87 + const has_mogr = url.includes('imageMogr2')
84 - // return url + (url.includes('?') ? '&' : '?') + 'imageMogr2/thumbnail/200x/strip/quality/70' 88 + if (!has_mogr) {
85 - // } 89 + return url + (url.includes('?') ? '&' : '?') + param
90 + }
86 return url 91 return url
87 } 92 }
88 } catch (e) { 93 } catch (e) {
...@@ -126,6 +131,12 @@ const show_proxy = computed({ ...@@ -126,6 +131,12 @@ const show_proxy = computed({
126 */ 131 */
127 const card_ref = ref(null) 132 const card_ref = ref(null)
128 133
134 +/**
135 + * @var {import('vue').Ref<boolean>} is_generating
136 + * @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。
137 + */
138 +const is_generating = ref(false)
139 +
129 /** 标题/副标题/介绍 */ 140 /** 标题/副标题/介绍 */
130 const title_text = computed(() => props.course?.title || '课程') 141 const title_text = computed(() => props.course?.title || '课程')
131 const subtitle_text = computed(() => props.course?.subtitle || '') 142 const subtitle_text = computed(() => props.course?.subtitle || '')
...@@ -176,6 +187,13 @@ const date_range_text = computed(() => { ...@@ -176,6 +187,13 @@ const date_range_text = computed(() => {
176 187
177 /** 封面图地址(含CDN压缩规则) */ 188 /** 封面图地址(含CDN压缩规则) */
178 const cover_src = computed(() => normalize_image_url(props.course?.cover || '')) 189 const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))
190 +/**
191 + * @var {import('vue').Ref<string>} cover_data_url
192 + * @description 封面图的 base64;优先用 base64 以避免跨域获取失败
193 + */
194 +const cover_data_url = ref('')
195 +/** 最终封面图地址:优先使用 base64,否则回退原地址 */
196 +const cover_final_src = computed(() => cover_data_url.value || cover_src.value)
179 197
180 /** 198 /**
181 * @function build_qr_service_url 199 * @function build_qr_service_url
...@@ -199,316 +217,207 @@ const qr_src = computed(() => { ...@@ -199,316 +217,207 @@ const qr_src = computed(() => {
199 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') 217 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
200 return build_qr_service_url(url) 218 return build_qr_service_url(url)
201 }) 219 })
220 +/**
221 + * @var {import('vue').Ref<string>} qr_data_url
222 + * @description 本地生成的二维码 base64,避免跨域图片导致生成失败
223 + */
224 +const qr_data_url = ref('')
225 +/** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */
226 +const qr_final_src = computed(() => qr_data_url.value || qr_src.value)
202 227
203 // 海报图片 dataURL(用于长按保存) 228 // 海报图片 dataURL(用于长按保存)
204 const poster_img_src = ref('') 229 const poster_img_src = ref('')
205 230
206 /** 231 /**
207 - * @function load_image 232 + * @function try_fetch_to_data_url
208 - * @description 以匿名跨域方式加载图片并追加时间戳防缓存;失败返回 null 233 + * @description 尝试将图片地址转为 base64;跨域或失败时返回空串
209 - * @param {string} src 图片地址 234 + * @param {string} url 图片地址
210 - * @returns {Promise<HTMLImageElement|null>} 加载成功的图片对象或 null 235 + * @returns {Promise<string>} base64 dataURL 或空串
211 */ 236 */
212 -function load_image(src) { 237 +async function try_fetch_to_data_url(url) {
213 - return new Promise((resolve) => { 238 + if (!url) return ''
214 - if (!src) return resolve(null) 239 + try {
215 - const img = new Image() 240 + const res = await fetch(url, { mode: 'cors', cache: 'no-cache', credentials: 'omit' })
216 - img.crossOrigin = 'anonymous' 241 + if (!res.ok) return ''
217 - const with_ts = src + (src.includes('?') ? '&' : '?') + 'v=' + Date.now() 242 + const blob = await res.blob()
218 - img.onload = () => resolve(img) 243 + return await new Promise((resolve) => {
219 - img.onerror = () => resolve(null) 244 + const reader = new FileReader()
220 - img.src = with_ts 245 + reader.onloadend = () => resolve(String(reader.result || ''))
221 - }) 246 + reader.onerror = () => resolve('')
222 -} 247 + reader.readAsDataURL(blob)
223 - 248 + })
224 -/** 249 + } catch (e) {
225 - * @function wrap_text 250 + return ''
226 - * @description 按最大宽度自动换行并限制最大行数;溢出末行追加省略号。
227 - * @param {CanvasRenderingContext2D} ctx 画布上下文
228 - * @param {string} text 文本内容
229 - * @param {number} max_w 最大宽度
230 - * @param {string} font 字体样式(如 'bold 32px sans-serif')
231 - * @param {number} line_h 行高
232 - * @param {number} max_lines 最大行数
233 - * @returns {string[]} 处理后的行数组
234 - */
235 -function wrap_text(ctx, text, max_w, font, line_h, max_lines) {
236 - ctx.font = font
237 - const content = (text || '').trim()
238 - if (!content) return []
239 - const lines = []
240 - let cur = ''
241 - const tokens = Array.from(content)
242 - tokens.forEach(ch => {
243 - const test = cur + ch
244 - if (ctx.measureText(test).width <= max_w) {
245 - cur = test
246 - } else {
247 - lines.push(cur)
248 - cur = ch
249 - }
250 - })
251 - if (cur) lines.push(cur)
252 - if (lines.length > max_lines) {
253 - const truncated = lines.slice(0, max_lines)
254 - const last = truncated[truncated.length - 1]
255 - truncated[truncated.length - 1] = (last || '').slice(0, Math.max(0, (last || '').length - 1)) + '…'
256 - return truncated
257 } 251 }
258 - return lines
259 } 252 }
260 253
261 /** 254 /**
262 - * @function rounded_rect_path 255 + * @function prepare_assets
263 - * @description 绘制圆角矩形路径(仅定义 path,不进行填充或描边)。 256 + * @description 在截图前准备资源:封面尝试转 base64、二维码本地生成为 base64。
264 - * @param {CanvasRenderingContext2D} ctx 画布上下文 257 + * @returns {Promise<void>}
265 - * @param {number} x 起始x
266 - * @param {number} y 起始y
267 - * @param {number} w 宽度
268 - * @param {number} h 高度
269 - * @param {number} r 圆角半径
270 - * @returns {void}
271 - */
272 -function rounded_rect_path(ctx, x, y, w, h, r) {
273 - const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2))
274 - ctx.beginPath()
275 - ctx.moveTo(x + rr, y)
276 - ctx.lineTo(x + w - rr, y)
277 - ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
278 - ctx.lineTo(x + w, y + h - rr)
279 - ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
280 - ctx.lineTo(x + rr, y + h)
281 - ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
282 - ctx.lineTo(x, y + rr)
283 - ctx.quadraticCurveTo(x, y, x + rr, y)
284 - ctx.closePath()
285 -}
286 -
287 -/**
288 - * @function rounded_top_rect_path
289 - * @description 绘制仅顶部两角为圆角、底部直角的矩形路径(用于封面区域裁剪)。
290 - * @param {CanvasRenderingContext2D} ctx 画布上下文
291 - * @param {number} x 起始x
292 - * @param {number} y 起始y
293 - * @param {number} w 宽度
294 - * @param {number} h 高度
295 - * @param {number} r 圆角半径(仅作用于顶部)
296 - * @returns {void}
297 */ 258 */
298 -function rounded_top_rect_path(ctx, x, y, w, h, r) { 259 +async function prepare_assets() {
299 - const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2)) 260 + // 二维码本地生成
300 - ctx.beginPath() 261 + try {
301 - // 顶部边 + 右上圆角 262 + const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
302 - ctx.moveTo(x + rr, y) 263 + const data_url = await QRCode.toDataURL(url, { margin: 2, width: 256, color: { dark: '#000000', light: '#ffffff' } })
303 - ctx.lineTo(x + w - rr, y) 264 + qr_data_url.value = data_url || ''
304 - ctx.quadraticCurveTo(x + w, y, x + w, y + rr) 265 + } catch (e) {
305 - // 右侧直边到底部直角 266 + qr_data_url.value = ''
306 - ctx.lineTo(x + w, y + h) 267 + }
307 - // 底部直边到左下直角 268 + // 封面尝试转 base64(若跨域失败则回退原地址)
308 - ctx.lineTo(x, y + h) 269 + try {
309 - // 左侧直边到左上圆角 270 + const b64 = await try_fetch_to_data_url(cover_src.value)
310 - ctx.lineTo(x, y + rr) 271 + cover_data_url.value = b64 || ''
311 - ctx.quadraticCurveTo(x, y, x + rr, y) 272 + } catch (e) {
312 - ctx.closePath() 273 + cover_data_url.value = ''
274 + }
313 } 275 }
314 276
315 /** 277 /**
316 * @function compose_poster 278 * @function compose_poster
317 - * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。 279 + * @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。
318 - * @returns {Promise<void>}
319 - */
320 -/**
321 - * @function compose_poster
322 - * @description 以 Canvas 合成海报:底部仅渲染标题与副标题,右侧居中排版;生成 dataURL 供长按保存。
323 * @returns {Promise<void>} 280 * @returns {Promise<void>}
324 */ 281 */
325 async function compose_poster() { 282 async function compose_poster() {
326 poster_img_src.value = '' 283 poster_img_src.value = ''
327 try { 284 try {
328 - // 固定设计宽度,整体高度动态计算以避免封面留白 285 + // 标记进入生成流程
329 - const width = 750 286 + is_generating.value = true
330 - const card_margin = 12 287 + // 准备资源,尽量使用 base64 避免跨域失败
331 - const card_x = card_margin 288 + await prepare_assets()
332 - const card_y = card_margin 289 + await nextTick()
333 - const card_w = width - card_margin * 2 290 + const node = card_ref.value
334 - const card_radius = 16 291 + if (!node) {
335 - 292 + is_generating.value = false
336 - // 预加载封面以获取真实宽高,按 contain(宽度占满)动态计算封面高度 293 + return
337 - const cover_img = await load_image(cover_src.value)
338 - let cover_h = Math.round(card_w * 2 / 3)
339 - if (cover_img) {
340 - const sw = cover_img.width
341 - const sh = cover_img.height
342 - const scale_w = card_w / Math.max(1, sw)
343 - cover_h = Math.round(sh * scale_w)
344 - }
345 -
346 - // 文本与二维码区尺寸计算:根据内容动态确定信息区高度
347 - const padding = 32
348 - const qr_size = 160
349 -
350 - const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
351 - const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
352 - const date_font = 'normal 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
353 - const footnote_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
354 -
355 - const measurer = document.createElement('canvas')
356 - const mctx = measurer.getContext('2d')
357 - const text_max_w = card_w - padding * 2 - qr_size - 20
358 -
359 - // 测量标题/副标题/日期,并在信息区顶部对齐排版(脚注单独贴底绘制)
360 - const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 44, 1)
361 - const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 32, 1) : []
362 - const date_lines = date_range_text.value ? wrap_text(mctx, date_range_text.value, text_max_w, date_font, 40, 1) : []
363 - // 文本行间距:用于增大标题/副标题/日期的间隔
364 - const line_gap = 10
365 - const lines_count = (title_lines.length ? 1 : 0) + (subtitle_lines.length ? 1 : 0) + (date_lines.length ? 1 : 0)
366 - const gaps_count = Math.max(0, lines_count - 1)
367 - const total_text_h = (title_lines.length ? 44 : 0)
368 - + (subtitle_lines.length ? 32 : 0)
369 - + (date_lines.length ? 40 : 0)
370 - + gaps_count * line_gap
371 - const min_gap = 24
372 - const footnote_line_h = 28
373 - const info_h = padding * 2 + Math.max(qr_size, total_text_h + min_gap + footnote_line_h)
374 - const card_h = cover_h + info_h
375 -
376 - const text_offset_y = 0
377 - const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
378 - const canvas = document.createElement('canvas')
379 - canvas.width = Math.round(width * dpr)
380 - canvas.height = Math.round((card_h + card_margin * 2) * dpr)
381 - const ctx = canvas.getContext('2d')
382 - ctx.scale(dpr, dpr)
383 -
384 - // 卡片阴影与背景(白色圆角卡片 + 灰色边框)
385 - ctx.save()
386 - rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
387 - ctx.shadowColor = 'rgba(0,0,0,0.12)'
388 - ctx.shadowBlur = 12
389 - ctx.shadowOffsetX = 0
390 - ctx.shadowOffsetY = 4
391 - ctx.fillStyle = '#ffffff'
392 - ctx.fill()
393 - ctx.restore()
394 -
395 - // 卡片边框
396 - ctx.save()
397 - rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
398 - ctx.strokeStyle = '#e5e7eb' // gray-200
399 - ctx.lineWidth = 2
400 - ctx.stroke()
401 - ctx.restore()
402 -
403 - // 卡片内容裁剪区域
404 - ctx.save()
405 - rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
406 - ctx.clip()
407 -
408 - // 封面图:宽度占满,高度按等比缩放(无留白)
409 - if (cover_img) {
410 - ctx.save()
411 - ctx.beginPath()
412 - // 仅顶部圆角,底部直角,避免封面底部出现弧形
413 - rounded_top_rect_path(ctx, card_x, card_y, card_w, cover_h, card_radius)
414 - ctx.clip()
415 - ctx.drawImage(cover_img, card_x, card_y, card_w, cover_h)
416 - ctx.restore()
417 - } else {
418 - ctx.fillStyle = '#f3f4f6' // gray-100
419 - ctx.fillRect(card_x, card_y, card_w, cover_h)
420 - ctx.fillStyle = '#9ca3af' // gray-400
421 - ctx.textAlign = 'center'
422 - ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
423 - ctx.fillText('封面加载失败', card_x + card_w / 2, card_y + cover_h / 2 + 10)
424 - ctx.textAlign = 'left'
425 - }
426 -
427 - // 二维码(本地生成避免跨域)
428 - const qr_canvas = document.createElement('canvas')
429 - qr_canvas.width = Math.round(qr_size * dpr)
430 - qr_canvas.height = Math.round(qr_size * dpr)
431 - const qr_url_val = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
432 - try {
433 - await QRCode.toCanvas(qr_canvas, qr_url_val, { width: Math.round(qr_size * dpr), margin: Math.round(2 * dpr), color: { dark: '#000000', light: '#ffffff' } })
434 - } catch (e) {
435 - const qctx = qr_canvas.getContext('2d')
436 - qctx.fillStyle = '#ffffff'
437 - qctx.fillRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr))
438 - qctx.strokeStyle = '#e5e7eb'
439 - qctx.strokeRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr))
440 - qctx.fillStyle = '#9ca3af'
441 - qctx.font = `${Math.round(16 * dpr)}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"`
442 - qctx.fillText('二维码生成失败', Math.round(12 * dpr), Math.round((qr_size * dpr) / 2))
443 - }
444 - ctx.drawImage(qr_canvas, card_x + padding, card_y + cover_h + padding, qr_size, qr_size)
445 -
446 - // 文案(右侧,顶部对齐)
447 - let tx = card_x + padding + qr_size + 20
448 - let ty = card_y + cover_h + padding
449 - ctx.fillStyle = '#1f2937' // gray-800
450 - ctx.font = title_font
451 - // 增加标题与后续文本的间距
452 - title_lines.forEach(line => { ctx.fillText(line, tx, ty + 34); ty += 44 + line_gap })
453 -
454 - ctx.fillStyle = '#6b7280' // gray-500
455 - ctx.font = subtitle_font
456 - // 增加副标题与后续文本的间距
457 - subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 24); ty += 32 + line_gap })
458 -
459 - ctx.fillStyle = '#9ca3af' // gray-400
460 - ctx.font = date_font
461 - // 日期作为最后一行,不再额外叠加间距
462 - date_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 })
463 -
464 - // 脚注:底部绿色提示文案(贴底显示)
465 - ctx.fillStyle = '#10b981' // green-500
466 - ctx.font = footnote_font
467 - let foot_y = card_y + cover_h + info_h - padding - 14
468 - const last_text_bottom_y = ty
469 - if (foot_y - last_text_bottom_y < min_gap) {
470 - foot_y = last_text_bottom_y + min_gap
471 - }
472 - ctx.fillText('扫码了解详情', tx, foot_y)
473 -
474 - // 恢复裁剪
475 - ctx.restore()
476 -
477 - // 生成 dataURL
478 - try {
479 - const data_url = canvas.toDataURL('image/png')
480 - poster_img_src.value = data_url
481 - } catch (e) {
482 - console.error('海报生成失败(跨域):', e)
483 - showToast('海报生成失败,已展示标准卡片,请长按保存截图')
484 - poster_img_src.value = ''
485 } 294 }
295 + // 等待容器内图片加载完成,避免首次截图丢失封面
296 + await wait_images_loaded(node)
297 + // 轻微延迟,确保弹窗过渡动画结束,布局稳定
298 + await new Promise(r => setTimeout(r, 80))
299 + // 克隆离屏节点,避免弹窗动画与布局抖动影响截图
300 + const { wrapper, clone, size } = create_offscreen_clone(node)
301 + await wait_images_loaded(clone, 1200)
302 + // 设置像素比例,提升清晰度(在高分屏上更清晰)
303 + const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1))
304 + // 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断
305 + const placeholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wm3T9kAAAAASUVORK5CYII='
306 + // 生成 PNG dataURL
307 + const data_url = await toPng(clone, {
308 + pixelRatio: pixel_ratio,
309 + cacheBust: true,
310 + backgroundColor: '#ffffff',
311 + imagePlaceholder: placeholder,
312 + fetchRequestInit: { mode: 'cors', cache: 'no-cache', credentials: 'omit' },
313 + // 避免外层 margin 影响截图结果
314 + style: { margin: '0' },
315 + width: Math.round(size.width),
316 + height: Math.round(size.height)
317 + })
318 + poster_img_src.value = data_url
319 + // 清理离屏节点
320 + if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper)
321 + is_generating.value = false
486 } catch (err) { 322 } catch (err) {
487 - console.error('compose_poster 异常:', err) 323 + console.error('html-to-image 生成海报失败:', err)
488 - showToast('海报生成失败,请稍后重试') 324 + showToast('海报生成失败,已展示标准卡片,请长按保存截图')
489 poster_img_src.value = '' 325 poster_img_src.value = ''
326 + is_generating.value = false
490 } 327 }
491 } 328 }
492 329
493 /** 330 /**
494 - * @function init_once 331 + * @function create_offscreen_clone
495 - * @description 组件挂载时生成一次海报,避免因弹框开关导致重复计算与变形。 332 + * @description 创建一个离屏的卡片克隆用于稳定截图,避免受弹窗动画与布局影响。
333 + * @param {HTMLElement} node 原始容器节点
334 + * @returns {{wrapper: HTMLElement, clone: HTMLElement, size: {width: number, height: number}}}
335 + */
336 +function create_offscreen_clone(node) {
337 + const rect = node.getBoundingClientRect()
338 + const size = { width: rect.width, height: rect.height }
339 + const wrapper = document.createElement('div')
340 + const clone = node.cloneNode(true)
341 + // 离屏包裹容器样式
342 + Object.assign(wrapper.style, {
343 + position: 'fixed',
344 + left: '-99999px',
345 + top: '-99999px',
346 + width: `${Math.round(size.width)}px`,
347 + height: `${Math.round(size.height)}px`,
348 + overflow: 'hidden',
349 + opacity: '0',
350 + pointerEvents: 'none',
351 + background: '#ffffff',
352 + zIndex: '-1000'
353 + })
354 + // 克隆节点样式校正,避免动画、阴影、滤镜干扰
355 + Object.assign(clone.style, {
356 + width: '100%',
357 + height: '100%',
358 + margin: '0',
359 + transform: 'none',
360 + filter: 'none',
361 + boxShadow: 'none'
362 + })
363 + wrapper.appendChild(clone)
364 + document.body.appendChild(wrapper)
365 + return { wrapper, clone, size }
366 +}
367 +
368 +/**
369 + * @function watch_open_and_generate
370 + * @description 仅在弹窗打开时开始生成海报;关闭不生成。每次打开都重新生成以保证信息最新。
496 * @returns {void} 371 * @returns {void}
497 */ 372 */
498 -onMounted(() => { 373 +watch(show_proxy, (opened) => {
499 - if (!poster_img_src.value) { 374 + if (opened) {
375 + if (is_generating.value) return
376 + poster_img_src.value = ''
500 nextTick(() => compose_poster()) 377 nextTick(() => compose_poster())
501 } 378 }
502 }) 379 })
503 380
504 /** 381 /**
505 * @function recompose_on_data_change 382 * @function recompose_on_data_change
506 - * @description 仅当课程数据或二维码地址发生变化时重新合成海报;避免因弹窗显隐导致的重复计算 383 + * @description 当弹窗处于打开状态且课程数据或二维码地址发生变化时重新合成海报;弹窗关闭时不生成
507 * @returns {void} 384 * @returns {void}
508 */ 385 */
509 -watch(() => [props.course, props.qr_url], () => { 386 +// 已移除对象引用监听,改为监听具体字段(见下方)
510 - nextTick(() => compose_poster()) 387 +
511 -}, { deep: false }) 388 +/**
389 + * @function wait_images_loaded
390 + * @description 等待卡片容器中的图片加载完成(或超时),避免首次截图遗漏封面。
391 + * @param {HTMLElement} node 容器节点
392 + * @param {number} timeout 超时时间毫秒
393 + * @returns {Promise<void>}
394 + */
395 +async function wait_images_loaded(node, timeout = 2500) {
396 + try {
397 + if (!node) return
398 + const imgs = Array.from(node.querySelectorAll('img'))
399 + if (!imgs.length) return
400 + await Promise.all(imgs.map(img => new Promise((resolve) => {
401 + if (img.complete) return resolve()
402 + let done = false
403 + const finish = () => { if (!done) { done = true; resolve() } }
404 + img.addEventListener('load', finish, { once: true })
405 + img.addEventListener('error', finish, { once: true })
406 + setTimeout(finish, timeout)
407 + })))
408 + } catch (_) {
409 + // 忽略加载异常,继续生成
410 + }
411 +}
412 +
413 +// 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成
414 +watch([() => props.course?.cover, () => props.qr_url], () => {
415 + if (show_proxy.value) {
416 + if (is_generating.value) return
417 + poster_img_src.value = ''
418 + nextTick(() => compose_poster())
419 + }
420 +})
512 </script> 421 </script>
513 422
514 <style lang="less" scoped> 423 <style lang="less" scoped>
...@@ -539,7 +448,7 @@ watch(() => [props.course, props.qr_url], () => { ...@@ -539,7 +448,7 @@ watch(() => [props.course, props.qr_url], () => {
539 overflow: hidden; 448 overflow: hidden;
540 .PosterQR { 449 .PosterQR {
541 img { 450 img {
542 - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); 451 + box-shadow: none;
543 } 452 }
544 } 453 }
545 } 454 }
......