hookehuyr

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

- 新增 rounded_top_rect_path 函数实现顶部圆角裁剪
- 封面图改为自适应高度并保持完整显示
- 动态计算信息区高度避免内容裁切
- 移除固定宽高比,提升不同尺寸封面适配性
......@@ -20,8 +20,8 @@
<!-- 生成失败或尚未生成时的降级展示(可长按截图保存) -->
<div v-else>
<!-- 上部封面图 -->
<div class="PosterCover">
<img :src="cover_src" alt="课程封面" class="w-full h-full object-cover" crossorigin="anonymous" />
<div class="PosterCover rounded-t-xl overflow-hidden">
<img :src="cover_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" />
</div>
<!-- 下部信息区:左二维码 + 右文案 -->
<div class="PosterInfo p-4">
......@@ -77,8 +77,13 @@ function normalize_image_url(src) {
if (!url) return ''
try {
const u = new URL(url, window.location.origin)
if (u.hostname === 'cdn.ipadbiz.cn' && !u.search) {
return `${url}`
if (u.hostname === 'cdn.ipadbiz.cn') {
// CDN 图片统一追加压缩参数,若已存在查询参数则使用 & 连接
// const has_mogr = url.includes('imageMogr2')
// if (!has_mogr) {
// return url + (url.includes('?') ? '&' : '?') + 'imageMogr2/thumbnail/200x/strip/quality/70'
// }
return url
}
} catch (e) {
// 非绝对路径或无法解析的场景,直接返回原值
......@@ -280,6 +285,34 @@ function rounded_rect_path(ctx, x, y, w, h, r) {
}
/**
* @function rounded_top_rect_path
* @description 绘制仅顶部两角为圆角、底部直角的矩形路径(用于封面区域裁剪)。
* @param {CanvasRenderingContext2D} ctx 画布上下文
* @param {number} x 起始x
* @param {number} y 起始y
* @param {number} w 宽度
* @param {number} h 高度
* @param {number} r 圆角半径(仅作用于顶部)
* @returns {void}
*/
function rounded_top_rect_path(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2))
ctx.beginPath()
// 顶部边 + 右上圆角
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
// 右侧直边到底部直角
ctx.lineTo(x + w, y + h)
// 底部直边到左下直角
ctx.lineTo(x, y + h)
// 左侧直边到左上圆角
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
/**
* @function compose_poster
* @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。
* @returns {Promise<void>}
......@@ -292,25 +325,27 @@ function rounded_rect_path(ctx, x, y, w, h, r) {
async function compose_poster() {
poster_img_src.value = ''
try {
// 固定设计宽度与纵横比,避免因容器导致放大模糊
// 固定设计宽度,整体高度动态计算以避免封面留白
const width = 750
const aspect_ratio = 1 // 高度 = 宽度 * 1
const height = Math.round(width * aspect_ratio)
// 卡片外边距,用于投影显示空间;卡片圆角与投影参数
const card_margin = 12
const card_x = card_margin
const card_y = card_margin
const card_w = width - card_margin * 2
const card_h = height - card_margin * 2
const card_radius = 16
// 调整封面与信息区比例:封面 2/3,高度留出更多信息区避免裁切(基于卡片内容高度)
const cover_h = Math.round(card_h * 2 / 3)
const info_h = card_h - cover_h
// 预加载封面以获取真实宽高,按 contain(宽度占满)动态计算封面高度
const cover_img = await load_image(cover_src.value)
let cover_h = Math.round(card_w * 2 / 3)
if (cover_img) {
const sw = cover_img.width
const sh = cover_img.height
const scale_w = card_w / Math.max(1, sw)
cover_h = Math.round(sh * scale_w)
}
// 文本与二维码区尺寸计算:根据内容动态确定信息区高度
const padding = 32
const info_body_h = info_h - padding * 2
// 二维码尺寸不超过信息区有效高度,保留少量安全边距,防止底部被裁切
const qr_size = Math.floor(Math.min(196, Math.max(96, info_body_h - 4)))
const qr_size = 160
const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
......@@ -333,11 +368,16 @@ async function compose_poster() {
+ (subtitle_lines.length ? 32 : 0)
+ (date_lines.length ? 40 : 0)
+ gaps_count * line_gap
const min_gap = 24
const footnote_line_h = 28
const info_h = padding * 2 + Math.max(qr_size, total_text_h + min_gap + footnote_line_h)
const card_h = cover_h + info_h
const text_offset_y = 0
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
const canvas = document.createElement('canvas')
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
canvas.height = Math.round((card_h + card_margin * 2) * dpr)
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
......@@ -365,34 +405,14 @@ async function compose_poster() {
rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius)
ctx.clip()
// 封面图(严格裁剪到封面区域,避免溢出到信息区)
const cover_img = await load_image(cover_src.value)
// 封面图:宽度占满,高度按等比缩放(无留白)
if (cover_img) {
const sw = cover_img.width
const sh = cover_img.height
const dest_ar = card_w / cover_h
const src_ar = sw / sh
let sx = 0, sy = 0, s_w = sw, s_h = sh
// 根据源图与目标区域的纵横比,选择水平或垂直裁剪
if (src_ar > dest_ar) {
// 源图更宽:水平裁剪
s_h = sh
s_w = Math.round(sh * dest_ar)
sx = Math.round((sw - s_w) / 2)
sy = 0
} else {
// 源图更窄或更高:垂直裁剪
s_w = sw
s_h = Math.round(sw / dest_ar)
sx = 0
sy = Math.round((sh - s_h) / 2)
}
// 目标区域严格限定在封面矩形
ctx.save()
ctx.beginPath()
ctx.rect(card_x, card_y, card_w, cover_h)
// 仅顶部圆角,底部直角,避免封面底部出现弧形
rounded_top_rect_path(ctx, card_x, card_y, card_w, cover_h, card_radius)
ctx.clip()
ctx.drawImage(cover_img, sx, sy, s_w, s_h, card_x, card_y, card_w, cover_h)
ctx.drawImage(cover_img, card_x, card_y, card_w, cover_h)
ctx.restore()
} else {
ctx.fillStyle = '#f3f4f6' // gray-100
......@@ -445,8 +465,6 @@ async function compose_poster() {
ctx.fillStyle = '#10b981' // green-500
ctx.font = footnote_font
let foot_y = card_y + cover_h + info_h - padding - 14
// 保证脚注与上方文本至少保留最小间距
const min_gap = 24
const last_text_bottom_y = ty
if (foot_y - last_text_bottom_y < min_gap) {
foot_y = last_text_bottom_y + min_gap
......@@ -498,27 +516,28 @@ watch(() => [props.course, props.qr_url], () => {
height: auto;
display: flex;
flex-direction: column;
.PosterCard {
width: 100%;
max-width: 750px;
height: auto;
margin: 0 auto;
display: block;
> img {
.PosterCard {
width: 100%;
max-width: 750px;
height: auto;
object-fit: contain;
margin: 0 auto;
display: block;
}
.PosterCover {
// 固定封面高度为卡片宽度的 2/3,避免溢出到信息区
aspect-ratio: 3 / 2;
width: 100%;
overflow: hidden;
}
.PosterInfo {
overflow: hidden;
.PosterQR {
> img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.PosterCover {
width: 100%;
// 使用自适应高度,内部图片使用 object-contain 保持完整显示
border-top-left-radius: 16px;
border-top-right-radius: 16px;
overflow: hidden;
}
.PosterInfo {
overflow: hidden;
.PosterQR {
img {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
......