feat(SharePoster): 优化海报封面显示与动态高度计算
- 新增 rounded_top_rect_path 函数实现顶部圆角裁剪 - 封面图改为自适应高度并保持完整显示 - 动态计算信息区高度避免内容裁切 - 移除固定宽高比,提升不同尺寸封面适配性
Showing
1 changed file
with
63 additions
and
44 deletions
| ... | @@ -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 { | ... | ... |
-
Please register or login to post a comment