hookehuyr

feat(SharePoster): 优化海报封面显示与动态高度计算

- 新增 rounded_top_rect_path 函数实现顶部圆角裁剪
- 封面图改为自适应高度并保持完整显示
- 动态计算信息区高度避免内容裁切
- 移除固定宽高比,提升不同尺寸封面适配性
...@@ -20,8 +20,8 @@ ...@@ -20,8 +20,8 @@
20 <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) --> 20 <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) -->
21 <div v-else> 21 <div v-else>
22 <!-- 上部封面图 --> 22 <!-- 上部封面图 -->
23 - <div class="PosterCover"> 23 + <div class="PosterCover rounded-t-xl overflow-hidden">
24 - <img :src="cover_src" alt="课程封面" class="w-full h-full object-cover" crossorigin="anonymous" /> 24 + <img :src="cover_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" />
25 </div> 25 </div>
26 <!-- 下部信息区:左二维码 + 右文案 --> 26 <!-- 下部信息区:左二维码 + 右文案 -->
27 <div class="PosterInfo p-4"> 27 <div class="PosterInfo p-4">
...@@ -77,8 +77,13 @@ function normalize_image_url(src) { ...@@ -77,8 +77,13 @@ function normalize_image_url(src) {
77 if (!url) return '' 77 if (!url) return ''
78 try { 78 try {
79 const u = new URL(url, window.location.origin) 79 const u = new URL(url, window.location.origin)
80 - if (u.hostname === 'cdn.ipadbiz.cn' && !u.search) { 80 + if (u.hostname === 'cdn.ipadbiz.cn') {
81 - return `${url}` 81 + // CDN 图片统一追加压缩参数,若已存在查询参数则使用 & 连接
82 + // const has_mogr = url.includes('imageMogr2')
83 + // if (!has_mogr) {
84 + // return url + (url.includes('?') ? '&' : '?') + 'imageMogr2/thumbnail/200x/strip/quality/70'
85 + // }
86 + return url
82 } 87 }
83 } catch (e) { 88 } catch (e) {
84 // 非绝对路径或无法解析的场景,直接返回原值 89 // 非绝对路径或无法解析的场景,直接返回原值
...@@ -280,6 +285,34 @@ function rounded_rect_path(ctx, x, y, w, h, r) { ...@@ -280,6 +285,34 @@ function rounded_rect_path(ctx, x, y, w, h, r) {
280 } 285 }
281 286
282 /** 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 + */
298 +function rounded_top_rect_path(ctx, x, y, w, h, r) {
299 + const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2))
300 + ctx.beginPath()
301 + // 顶部边 + 右上圆角
302 + ctx.moveTo(x + rr, y)
303 + ctx.lineTo(x + w - rr, y)
304 + ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
305 + // 右侧直边到底部直角
306 + ctx.lineTo(x + w, y + h)
307 + // 底部直边到左下直角
308 + ctx.lineTo(x, y + h)
309 + // 左侧直边到左上圆角
310 + ctx.lineTo(x, y + rr)
311 + ctx.quadraticCurveTo(x, y, x + rr, y)
312 + ctx.closePath()
313 +}
314 +
315 +/**
283 * @function compose_poster 316 * @function compose_poster
284 * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。 317 * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。
285 * @returns {Promise<void>} 318 * @returns {Promise<void>}
...@@ -292,25 +325,27 @@ function rounded_rect_path(ctx, x, y, w, h, r) { ...@@ -292,25 +325,27 @@ function rounded_rect_path(ctx, x, y, w, h, r) {
292 async function compose_poster() { 325 async function compose_poster() {
293 poster_img_src.value = '' 326 poster_img_src.value = ''
294 try { 327 try {
295 - // 固定设计宽度与纵横比,避免因容器导致放大模糊 328 + // 固定设计宽度,整体高度动态计算以避免封面留白
296 const width = 750 329 const width = 750
297 - const aspect_ratio = 1 // 高度 = 宽度 * 1
298 - const height = Math.round(width * aspect_ratio)
299 - // 卡片外边距,用于投影显示空间;卡片圆角与投影参数
300 const card_margin = 12 330 const card_margin = 12
301 const card_x = card_margin 331 const card_x = card_margin
302 const card_y = card_margin 332 const card_y = card_margin
303 const card_w = width - card_margin * 2 333 const card_w = width - card_margin * 2
304 - const card_h = height - card_margin * 2
305 const card_radius = 16 334 const card_radius = 16
306 335
307 - // 调整封面与信息区比例:封面 2/3,高度留出更多信息区避免裁切(基于卡片内容高度) 336 + // 预加载封面以获取真实宽高,按 contain(宽度占满)动态计算封面高度
308 - const cover_h = Math.round(card_h * 2 / 3) 337 + const cover_img = await load_image(cover_src.value)
309 - const info_h = card_h - cover_h 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 + // 文本与二维码区尺寸计算:根据内容动态确定信息区高度
310 const padding = 32 347 const padding = 32
311 - const info_body_h = info_h - padding * 2 348 + const qr_size = 160
312 - // 二维码尺寸不超过信息区有效高度,保留少量安全边距,防止底部被裁切
313 - const qr_size = Math.floor(Math.min(196, Math.max(96, info_body_h - 4)))
314 349
315 const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' 350 const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
316 const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' 351 const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
...@@ -333,11 +368,16 @@ async function compose_poster() { ...@@ -333,11 +368,16 @@ async function compose_poster() {
333 + (subtitle_lines.length ? 32 : 0) 368 + (subtitle_lines.length ? 32 : 0)
334 + (date_lines.length ? 40 : 0) 369 + (date_lines.length ? 40 : 0)
335 + gaps_count * line_gap 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 +
336 const text_offset_y = 0 376 const text_offset_y = 0
337 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)) 377 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
338 const canvas = document.createElement('canvas') 378 const canvas = document.createElement('canvas')
339 canvas.width = Math.round(width * dpr) 379 canvas.width = Math.round(width * dpr)
340 - canvas.height = Math.round(height * dpr) 380 + canvas.height = Math.round((card_h + card_margin * 2) * dpr)
341 const ctx = canvas.getContext('2d') 381 const ctx = canvas.getContext('2d')
342 ctx.scale(dpr, dpr) 382 ctx.scale(dpr, dpr)
343 383
...@@ -365,34 +405,14 @@ async function compose_poster() { ...@@ -365,34 +405,14 @@ async function compose_poster() {
365 rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius) 405 rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
366 ctx.clip() 406 ctx.clip()
367 407
368 - // 封面图(严格裁剪到封面区域,避免溢出到信息区) 408 + // 封面图:宽度占满,高度按等比缩放(无留白)
369 - const cover_img = await load_image(cover_src.value)
370 if (cover_img) { 409 if (cover_img) {
371 - const sw = cover_img.width
372 - const sh = cover_img.height
373 - const dest_ar = card_w / cover_h
374 - const src_ar = sw / sh
375 - let sx = 0, sy = 0, s_w = sw, s_h = sh
376 - // 根据源图与目标区域的纵横比,选择水平或垂直裁剪
377 - if (src_ar > dest_ar) {
378 - // 源图更宽:水平裁剪
379 - s_h = sh
380 - s_w = Math.round(sh * dest_ar)
381 - sx = Math.round((sw - s_w) / 2)
382 - sy = 0
383 - } else {
384 - // 源图更窄或更高:垂直裁剪
385 - s_w = sw
386 - s_h = Math.round(sw / dest_ar)
387 - sx = 0
388 - sy = Math.round((sh - s_h) / 2)
389 - }
390 - // 目标区域严格限定在封面矩形
391 ctx.save() 410 ctx.save()
392 ctx.beginPath() 411 ctx.beginPath()
393 - ctx.rect(card_x, card_y, card_w, cover_h) 412 + // 仅顶部圆角,底部直角,避免封面底部出现弧形
413 + rounded_top_rect_path(ctx, card_x, card_y, card_w, cover_h, card_radius)
394 ctx.clip() 414 ctx.clip()
395 - ctx.drawImage(cover_img, sx, sy, s_w, s_h, card_x, card_y, card_w, cover_h) 415 + ctx.drawImage(cover_img, card_x, card_y, card_w, cover_h)
396 ctx.restore() 416 ctx.restore()
397 } else { 417 } else {
398 ctx.fillStyle = '#f3f4f6' // gray-100 418 ctx.fillStyle = '#f3f4f6' // gray-100
...@@ -445,8 +465,6 @@ async function compose_poster() { ...@@ -445,8 +465,6 @@ async function compose_poster() {
445 ctx.fillStyle = '#10b981' // green-500 465 ctx.fillStyle = '#10b981' // green-500
446 ctx.font = footnote_font 466 ctx.font = footnote_font
447 let foot_y = card_y + cover_h + info_h - padding - 14 467 let foot_y = card_y + cover_h + info_h - padding - 14
448 - // 保证脚注与上方文本至少保留最小间距
449 - const min_gap = 24
450 const last_text_bottom_y = ty 468 const last_text_bottom_y = ty
451 if (foot_y - last_text_bottom_y < min_gap) { 469 if (foot_y - last_text_bottom_y < min_gap) {
452 foot_y = last_text_bottom_y + min_gap 470 foot_y = last_text_bottom_y + min_gap
...@@ -511,9 +529,10 @@ watch(() => [props.course, props.qr_url], () => { ...@@ -511,9 +529,10 @@ watch(() => [props.course, props.qr_url], () => {
511 display: block; 529 display: block;
512 } 530 }
513 .PosterCover { 531 .PosterCover {
514 - // 固定封面高度为卡片宽度的 2/3,避免溢出到信息区
515 - aspect-ratio: 3 / 2;
516 width: 100%; 532 width: 100%;
533 + // 使用自适应高度,内部图片使用 object-contain 保持完整显示
534 + border-top-left-radius: 16px;
535 + border-top-right-radius: 16px;
517 overflow: hidden; 536 overflow: hidden;
518 } 537 }
519 .PosterInfo { 538 .PosterInfo {
......