feat(SharePoster): 优化海报生成样式并移除分享按钮
- 为海报添加圆角边框和阴影效果 - 调整海报内部元素布局和样式 - 移除课程详情页的分享按钮功能
Showing
2 changed files
with
75 additions
and
17 deletions
| ... | @@ -14,7 +14,8 @@ | ... | @@ -14,7 +14,8 @@ |
| 14 | </div> | 14 | </div> |
| 15 | 15 | ||
| 16 | <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 --> | 16 | <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 --> |
| 17 | - <div class="PosterCard bg-white rounded-xl shadow-md overflow-hidden mx-auto" ref="card_ref"> | 17 | + <!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 --> |
| 18 | + <div :class="poster_img_src ? 'PosterCard mx-auto' : 'PosterCard bg-white rounded-xl shadow-md overflow-hidden mx-auto'" ref="card_ref"> | ||
| 18 | <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" /> | 19 | <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" /> |
| 19 | <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) --> | 20 | <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) --> |
| 20 | <div v-else> | 21 | <div v-else> |
| ... | @@ -231,6 +232,32 @@ function wrap_text(ctx, text, max_w, font, line_h, max_lines) { | ... | @@ -231,6 +232,32 @@ function wrap_text(ctx, text, max_w, font, line_h, max_lines) { |
| 231 | } | 232 | } |
| 232 | 233 | ||
| 233 | /** | 234 | /** |
| 235 | + * @function rounded_rect_path | ||
| 236 | + * @description 绘制圆角矩形路径(仅定义 path,不进行填充或描边)。 | ||
| 237 | + * @param {CanvasRenderingContext2D} ctx 画布上下文 | ||
| 238 | + * @param {number} x 起始x | ||
| 239 | + * @param {number} y 起始y | ||
| 240 | + * @param {number} w 宽度 | ||
| 241 | + * @param {number} h 高度 | ||
| 242 | + * @param {number} r 圆角半径 | ||
| 243 | + * @returns {void} | ||
| 244 | + */ | ||
| 245 | +function rounded_rect_path(ctx, x, y, w, h, r) { | ||
| 246 | + const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2)) | ||
| 247 | + ctx.beginPath() | ||
| 248 | + ctx.moveTo(x + rr, y) | ||
| 249 | + ctx.lineTo(x + w - rr, y) | ||
| 250 | + ctx.quadraticCurveTo(x + w, y, x + w, y + rr) | ||
| 251 | + ctx.lineTo(x + w, y + h - rr) | ||
| 252 | + ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h) | ||
| 253 | + ctx.lineTo(x + rr, y + h) | ||
| 254 | + ctx.quadraticCurveTo(x, y + h, x, y + h - rr) | ||
| 255 | + ctx.lineTo(x, y + rr) | ||
| 256 | + ctx.quadraticCurveTo(x, y, x + rr, y) | ||
| 257 | + ctx.closePath() | ||
| 258 | +} | ||
| 259 | + | ||
| 260 | +/** | ||
| 234 | * @function compose_poster | 261 | * @function compose_poster |
| 235 | * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。 | 262 | * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。 |
| 236 | * @returns {Promise<void>} | 263 | * @returns {Promise<void>} |
| ... | @@ -247,9 +274,17 @@ async function compose_poster() { | ... | @@ -247,9 +274,17 @@ async function compose_poster() { |
| 247 | const width = 750 | 274 | const width = 750 |
| 248 | const aspect_ratio = 1 // 高度 = 宽度 * 1 | 275 | const aspect_ratio = 1 // 高度 = 宽度 * 1 |
| 249 | const height = Math.round(width * aspect_ratio) | 276 | const height = Math.round(width * aspect_ratio) |
| 250 | - // 调整封面与信息区比例:封面 2/3,高度留出更多信息区避免裁切 | 277 | + // 卡片外边距,用于投影显示空间;卡片圆角与投影参数 |
| 251 | - const cover_h = Math.round(height * 2 / 3) | 278 | + const card_margin = 12 |
| 252 | - const info_h = height - cover_h | 279 | + const card_x = card_margin |
| 280 | + const card_y = card_margin | ||
| 281 | + const card_w = width - card_margin * 2 | ||
| 282 | + const card_h = height - card_margin * 2 | ||
| 283 | + const card_radius = 16 | ||
| 284 | + | ||
| 285 | + // 调整封面与信息区比例:封面 2/3,高度留出更多信息区避免裁切(基于卡片内容高度) | ||
| 286 | + const cover_h = Math.round(card_h * 2 / 3) | ||
| 287 | + const info_h = card_h - cover_h | ||
| 253 | const padding = 32 | 288 | const padding = 32 |
| 254 | const info_body_h = info_h - padding * 2 | 289 | const info_body_h = info_h - padding * 2 |
| 255 | // 二维码尺寸不超过信息区有效高度,保留少量安全边距,防止底部被裁切 | 290 | // 二维码尺寸不超过信息区有效高度,保留少量安全边距,防止底部被裁切 |
| ... | @@ -260,7 +295,7 @@ async function compose_poster() { | ... | @@ -260,7 +295,7 @@ async function compose_poster() { |
| 260 | 295 | ||
| 261 | const measurer = document.createElement('canvas') | 296 | const measurer = document.createElement('canvas') |
| 262 | const mctx = measurer.getContext('2d') | 297 | const mctx = measurer.getContext('2d') |
| 263 | - const text_max_w = width - padding * 2 - qr_size - 20 | 298 | + const text_max_w = card_w - padding * 2 - qr_size - 20 |
| 264 | 299 | ||
| 265 | // 仅测量标题与副标题,并在信息区垂直居中排版 | 300 | // 仅测量标题与副标题,并在信息区垂直居中排版 |
| 266 | const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 44, 1) | 301 | const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 44, 1) |
| ... | @@ -274,26 +309,46 @@ async function compose_poster() { | ... | @@ -274,26 +309,46 @@ async function compose_poster() { |
| 274 | const ctx = canvas.getContext('2d') | 309 | const ctx = canvas.getContext('2d') |
| 275 | ctx.scale(dpr, dpr) | 310 | ctx.scale(dpr, dpr) |
| 276 | 311 | ||
| 277 | - // 背景 | 312 | + // 卡片阴影与背景(白色圆角卡片 + 灰色边框) |
| 313 | + ctx.save() | ||
| 314 | + rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius) | ||
| 315 | + ctx.shadowColor = 'rgba(0,0,0,0.12)' | ||
| 316 | + ctx.shadowBlur = 12 | ||
| 317 | + ctx.shadowOffsetX = 0 | ||
| 318 | + ctx.shadowOffsetY = 4 | ||
| 278 | ctx.fillStyle = '#ffffff' | 319 | ctx.fillStyle = '#ffffff' |
| 279 | - ctx.fillRect(0, 0, width, height) | 320 | + ctx.fill() |
| 321 | + ctx.restore() | ||
| 322 | + | ||
| 323 | + // 卡片边框 | ||
| 324 | + ctx.save() | ||
| 325 | + rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius) | ||
| 326 | + ctx.strokeStyle = '#e5e7eb' // gray-200 | ||
| 327 | + ctx.lineWidth = 2 | ||
| 328 | + ctx.stroke() | ||
| 329 | + ctx.restore() | ||
| 330 | + | ||
| 331 | + // 卡片内容裁剪区域 | ||
| 332 | + ctx.save() | ||
| 333 | + rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius) | ||
| 334 | + ctx.clip() | ||
| 280 | 335 | ||
| 281 | // 封面图(对象填充) | 336 | // 封面图(对象填充) |
| 282 | const cover_img = await load_image(cover_src.value) | 337 | const cover_img = await load_image(cover_src.value) |
| 283 | if (cover_img) { | 338 | if (cover_img) { |
| 284 | - const scale = Math.max(width / cover_img.width, cover_h / cover_img.height) | 339 | + const scale = Math.max(card_w / cover_img.width, cover_h / cover_img.height) |
| 285 | const dw = cover_img.width * scale | 340 | const dw = cover_img.width * scale |
| 286 | const dh = cover_img.height * scale | 341 | const dh = cover_img.height * scale |
| 287 | - const dx = (width - dw) / 2 | 342 | + const dx = card_x + (card_w - dw) / 2 |
| 288 | - const dy = (cover_h - dh) / 2 | 343 | + const dy = card_y + (cover_h - dh) / 2 |
| 289 | ctx.drawImage(cover_img, dx, dy, dw, dh) | 344 | ctx.drawImage(cover_img, dx, dy, dw, dh) |
| 290 | } else { | 345 | } else { |
| 291 | ctx.fillStyle = '#f3f4f6' // gray-100 | 346 | ctx.fillStyle = '#f3f4f6' // gray-100 |
| 292 | - ctx.fillRect(0, 0, width, cover_h) | 347 | + ctx.fillRect(card_x, card_y, card_w, cover_h) |
| 293 | ctx.fillStyle = '#9ca3af' // gray-400 | 348 | ctx.fillStyle = '#9ca3af' // gray-400 |
| 294 | ctx.textAlign = 'center' | 349 | ctx.textAlign = 'center' |
| 295 | ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | 350 | ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' |
| 296 | - ctx.fillText('封面加载失败', width / 2, cover_h / 2 + 10) | 351 | + ctx.fillText('封面加载失败', card_x + card_w / 2, card_y + cover_h / 2 + 10) |
| 297 | ctx.textAlign = 'left' | 352 | ctx.textAlign = 'left' |
| 298 | } | 353 | } |
| 299 | 354 | ||
| ... | @@ -314,11 +369,11 @@ async function compose_poster() { | ... | @@ -314,11 +369,11 @@ async function compose_poster() { |
| 314 | qctx.font = `${Math.round(16 * dpr)}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"` | 369 | qctx.font = `${Math.round(16 * dpr)}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"` |
| 315 | qctx.fillText('二维码生成失败', Math.round(12 * dpr), Math.round((qr_size * dpr) / 2)) | 370 | qctx.fillText('二维码生成失败', Math.round(12 * dpr), Math.round((qr_size * dpr) / 2)) |
| 316 | } | 371 | } |
| 317 | - ctx.drawImage(qr_canvas, padding, cover_h + padding, qr_size, qr_size) | 372 | + ctx.drawImage(qr_canvas, card_x + padding, card_y + cover_h + padding, qr_size, qr_size) |
| 318 | 373 | ||
| 319 | // 文案(右侧,仅标题 + 副标题,整体居中) | 374 | // 文案(右侧,仅标题 + 副标题,整体居中) |
| 320 | - let tx = padding + qr_size + 20 | 375 | + let tx = card_x + padding + qr_size + 20 |
| 321 | - let ty = cover_h + padding + text_offset_y | 376 | + let ty = card_y + cover_h + padding + text_offset_y |
| 322 | ctx.fillStyle = '#1f2937' // gray-800 | 377 | ctx.fillStyle = '#1f2937' // gray-800 |
| 323 | ctx.font = title_font | 378 | ctx.font = title_font |
| 324 | title_lines.forEach(line => { ctx.fillText(line, tx, ty + 34); ty += 44 }) | 379 | title_lines.forEach(line => { ctx.fillText(line, tx, ty + 34); ty += 44 }) |
| ... | @@ -327,6 +382,9 @@ async function compose_poster() { | ... | @@ -327,6 +382,9 @@ async function compose_poster() { |
| 327 | ctx.font = subtitle_font | 382 | ctx.font = subtitle_font |
| 328 | subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 24); ty += 32 }) | 383 | subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 24); ty += 32 }) |
| 329 | 384 | ||
| 385 | + // 恢复裁剪 | ||
| 386 | + ctx.restore() | ||
| 387 | + | ||
| 330 | // 生成 dataURL | 388 | // 生成 dataURL |
| 331 | try { | 389 | try { |
| 332 | const data_url = canvas.toDataURL('image/png') | 390 | const data_url = canvas.toDataURL('image/png') | ... | ... |
| ... | @@ -224,7 +224,7 @@ | ... | @@ -224,7 +224,7 @@ |
| 224 | </svg> | 224 | </svg> |
| 225 | 咨询 | 225 | 咨询 |
| 226 | </button> | 226 | </button> |
| 227 | - <button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster"> | 227 | + <!-- <button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster"> |
| 228 | <svg | 228 | <svg |
| 229 | xmlns="http://www.w3.org/2000/svg" | 229 | xmlns="http://www.w3.org/2000/svg" |
| 230 | class="h-6 w-6" | 230 | class="h-6 w-6" |
| ... | @@ -240,7 +240,7 @@ | ... | @@ -240,7 +240,7 @@ |
| 240 | /> | 240 | /> |
| 241 | </svg> | 241 | </svg> |
| 242 | 分享 | 242 | 分享 |
| 243 | - </button> | 243 | + </button> --> |
| 244 | </div> | 244 | </div> |
| 245 | <div class="flex items-center"> | 245 | <div class="flex items-center"> |
| 246 | <div v-if="!course?.is_buy" class="mr-2"> | 246 | <div v-if="!course?.is_buy" class="mr-2"> | ... | ... |
-
Please register or login to post a comment