hookehuyr

feat(SharePoster): 优化海报生成样式并移除分享按钮

- 为海报添加圆角边框和阴影效果
- 调整海报内部元素布局和样式
- 移除课程详情页的分享按钮功能
...@@ -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">
......