hookehuyr

feat(SharePoster): 优化海报生成逻辑并启用分享按钮

重构海报生成组件,简化文案显示仅保留标题和副标题
使用项目内部接口生成二维码替代第三方服务
调整海报布局比例和字体大小以提升视觉效果
移除图片压缩参数并优化组件初始化逻辑
...@@ -31,10 +31,8 @@ ...@@ -31,10 +31,8 @@
31 </div> 31 </div>
32 <!-- 右侧文案 --> 32 <!-- 右侧文案 -->
33 <div class="flex-1"> 33 <div class="flex-1">
34 - <div class="text-base font-semibold text-gray-800 truncate">{{ title_text }}</div> 34 + <div class="text-lg font-semibold text-gray-800 truncate">{{ title_text }}</div>
35 - <div class="text-xs text-gray-500 mt-1 truncate" v-if="subtitle_text">{{ subtitle_text }}</div> 35 + <div class="text-sm text-gray-500 mt-1 truncate" v-if="subtitle_text">{{ subtitle_text }}</div>
36 - <div class="text-sm text-gray-700 mt-2 leading-6 line-clamp-3" v-if="intro_text">{{ intro_text }}</div>
37 - <div class="text-xs text-gray-400 mt-2" v-if="date_range_text">{{ date_range_text }}</div>
38 </div> 36 </div>
39 </div> 37 </div>
40 </div> 38 </div>
...@@ -52,7 +50,7 @@ ...@@ -52,7 +50,7 @@
52 </template> 50 </template>
53 51
54 <script setup> 52 <script setup>
55 -import { ref, computed, watch, nextTick } from 'vue' 53 +import { ref, computed, watch, nextTick, onMounted } from 'vue'
56 import QRCode from 'qrcode' 54 import QRCode from 'qrcode'
57 import { showToast } from 'vant' 55 import { showToast } from 'vant'
58 56
...@@ -68,7 +66,6 @@ import { showToast } from 'vant' ...@@ -68,7 +66,6 @@ import { showToast } from 'vant'
68 66
69 /** 67 /**
70 * @function normalize_image_url 68 * @function normalize_image_url
71 - * @description 若图片域名为 `cdn.ipadbiz.cn`,追加压缩参数 `?imageMogr2/thumbnail/200x/strip/quality/70`。
72 * @param {string} src 原始图片地址 69 * @param {string} src 原始图片地址
73 * @returns {string} 处理后的图片地址 70 * @returns {string} 处理后的图片地址
74 */ 71 */
...@@ -78,7 +75,7 @@ function normalize_image_url(src) { ...@@ -78,7 +75,7 @@ function normalize_image_url(src) {
78 try { 75 try {
79 const u = new URL(url, window.location.origin) 76 const u = new URL(url, window.location.origin)
80 if (u.hostname === 'cdn.ipadbiz.cn' && !u.search) { 77 if (u.hostname === 'cdn.ipadbiz.cn' && !u.search) {
81 - return `${url}?imageMogr2/thumbnail/200x/strip/quality/70` 78 + return `${url}`
82 } 79 }
83 } catch (e) { 80 } catch (e) {
84 // 非绝对路径或无法解析的场景,直接返回原值 81 // 非绝对路径或无法解析的场景,直接返回原值
...@@ -152,11 +149,27 @@ const date_range_text = computed(() => { ...@@ -152,11 +149,27 @@ const date_range_text = computed(() => {
152 /** 封面图地址(含CDN压缩规则) */ 149 /** 封面图地址(含CDN压缩规则) */
153 const cover_src = computed(() => normalize_image_url(props.course?.cover || '')) 150 const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))
154 151
155 -/** 二维码图片(使用在线服务生成) */ 152 +/**
153 + * @function build_qr_service_url
154 + * @description 按项目接口规则拼接二维码服务地址:`origin/admin/?m=srv&a=get_qrcode&key=实际地址`
155 + * @param {string} raw_url 实际跳转地址
156 + * @returns {string} 可直接用于 img 的二维码服务地址
157 + */
158 +function build_qr_service_url(raw_url) {
159 + const origin = import.meta.env.VITE_PROXY_TARGET || (typeof window !== 'undefined' ? window.location.origin : '')
160 + // 规整 origin,确保以 / 结尾便于 URL 组合
161 + const base = origin.endsWith('/') ? origin : origin + '/'
162 + const api = new URL('admin/?m=srv&a=get_qrcode', base)
163 + const key = encodeURIComponent(raw_url || '')
164 + api.searchParams.set('key', raw_url ? raw_url : '')
165 + // 某些服务不识别 searchParams 的编码方式,这里直接拼接编码后的 key 以确保兼容
166 + return `${api.origin}${api.pathname}?m=srv&a=get_qrcode&key=${key}`
167 +}
168 +
169 +/** 二维码图片(通过项目接口获取) */
156 const qr_src = computed(() => { 170 const qr_src = computed(() => {
157 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') 171 const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
158 - const size = '180x180' 172 + return build_qr_service_url(url)
159 - return `https://api.qrserver.com/v1/create-qr-code/?size=${size}&data=${encodeURIComponent(url)}`
160 }) 173 })
161 174
162 // 海报图片 dataURL(用于长按保存) 175 // 海报图片 dataURL(用于长按保存)
...@@ -224,7 +237,7 @@ function wrap_text(ctx, text, max_w, font, line_h, max_lines) { ...@@ -224,7 +237,7 @@ function wrap_text(ctx, text, max_w, font, line_h, max_lines) {
224 */ 237 */
225 /** 238 /**
226 * @function compose_poster 239 * @function compose_poster
227 - * @description 以 Canvas 合成海报:根据容器宽度动态计算尺寸;封面高度为底部信息区的 3 倍(整体 3:1 比例),生成 dataURL 供长按保存。 240 + * @description 以 Canvas 合成海报:底部仅渲染标题与副标题,右侧居中排版;生成 dataURL 供长按保存。
228 * @returns {Promise<void>} 241 * @returns {Promise<void>}
229 */ 242 */
230 async function compose_poster() { 243 async function compose_poster() {
...@@ -232,37 +245,28 @@ async function compose_poster() { ...@@ -232,37 +245,28 @@ async function compose_poster() {
232 try { 245 try {
233 // 固定设计宽度与纵横比,避免因容器导致放大模糊 246 // 固定设计宽度与纵横比,避免因容器导致放大模糊
234 const width = 750 247 const width = 750
235 - const aspect_ratio = 1.6 // 高度 = 宽度 * 1.6(稳定的长方形比例) 248 + const aspect_ratio = 1 // 高度 = 宽度 * 1
236 const height = Math.round(width * aspect_ratio) 249 const height = Math.round(width * aspect_ratio)
237 - const cover_h = Math.round(height * 3 / 4) 250 + // 调整封面与信息区比例:封面 2/3,高度留出更多信息区避免裁切
251 + const cover_h = Math.round(height * 2 / 3)
238 const info_h = height - cover_h 252 const info_h = height - cover_h
239 const padding = 32 253 const padding = 32
240 const info_body_h = info_h - padding * 2 254 const info_body_h = info_h - padding * 2
241 - const qr_size = 196 255 + // 二维码尺寸不超过信息区有效高度,保留少量安全边距,防止底部被裁切
256 + const qr_size = Math.floor(Math.min(196, Math.max(96, info_body_h - 4)))
242 257
243 - const title_font = 'bold 32px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' 258 + const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
244 - const subtitle_font = 'normal 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' 259 + const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
245 - const intro_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
246 - const date_font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
247 260
248 const measurer = document.createElement('canvas') 261 const measurer = document.createElement('canvas')
249 const mctx = measurer.getContext('2d') 262 const mctx = measurer.getContext('2d')
250 const text_max_w = width - padding * 2 - qr_size - 20 263 const text_max_w = width - padding * 2 - qr_size - 20
251 264
252 - // 先测量固定行数的标题/副标题/日期 265 + // 仅测量标题与副标题,并在信息区垂直居中排版
253 - const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 40, 1) 266 + const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 44, 1)
254 - const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 30, 1) : [] 267 + const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 32, 1) : []
255 - const date_lines = date_range_text.value ? wrap_text(mctx, date_range_text.value, text_max_w, date_font, 28, 1) : [] 268 + const total_text_h = (title_lines.length ? 44 : 0) + (subtitle_lines.length ? 32 : 0)
256 - 269 + const text_offset_y = Math.max(0, Math.floor((info_body_h - total_text_h) / 2))
257 - const reserved_h = (title_lines.length ? title_lines.length * 40 : 0)
258 - + (subtitle_lines.length ? 30 : 0)
259 - + (date_lines.length ? 28 + 8 : 0)
260 -
261 - // 动态计算介绍文本的最大行数以适配信息区高度
262 - const intro_line_h = 34
263 - const intro_space = Math.max(0, info_body_h - reserved_h - 8)
264 - const max_intro_lines = Math.max(0, Math.floor(intro_space / intro_line_h))
265 - const intro_lines = intro_text.value ? wrap_text(mctx, intro_text.value, text_max_w, intro_font, intro_line_h, Math.max(0, max_intro_lines)) : []
266 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)) 270 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
267 const canvas = document.createElement('canvas') 271 const canvas = document.createElement('canvas')
268 canvas.width = Math.round(width * dpr) 272 canvas.width = Math.round(width * dpr)
...@@ -312,24 +316,16 @@ async function compose_poster() { ...@@ -312,24 +316,16 @@ async function compose_poster() {
312 } 316 }
313 ctx.drawImage(qr_canvas, padding, cover_h + padding, qr_size, qr_size) 317 ctx.drawImage(qr_canvas, padding, cover_h + padding, qr_size, qr_size)
314 318
315 - // 文案(右侧) 319 + // 文案(右侧,仅标题 + 副标题,整体居中
316 let tx = padding + qr_size + 20 320 let tx = padding + qr_size + 20
317 - let ty = cover_h + padding 321 + let ty = cover_h + padding + text_offset_y
318 ctx.fillStyle = '#1f2937' // gray-800 322 ctx.fillStyle = '#1f2937' // gray-800
319 ctx.font = title_font 323 ctx.font = title_font
320 - title_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 }) 324 + title_lines.forEach(line => { ctx.fillText(line, tx, ty + 34); ty += 44 })
321 325
322 ctx.fillStyle = '#6b7280' // gray-500 326 ctx.fillStyle = '#6b7280' // gray-500
323 ctx.font = subtitle_font 327 ctx.font = subtitle_font
324 - subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 18); ty += 30 }) 328 + subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 24); ty += 32 })
325 -
326 - ctx.fillStyle = '#374151' // gray-700
327 - ctx.font = intro_font
328 - intro_lines.forEach(line => { ctx.fillText(line, tx, ty + 22); ty += 34 })
329 -
330 - ctx.fillStyle = '#9ca3af' // gray-400
331 - ctx.font = date_font
332 - date_lines.forEach(line => { ctx.fillText(line, tx, ty + 16); ty += 28 })
333 329
334 // 生成 dataURL 330 // 生成 dataURL
335 try { 331 try {
...@@ -347,14 +343,25 @@ async function compose_poster() { ...@@ -347,14 +343,25 @@ async function compose_poster() {
347 } 343 }
348 } 344 }
349 345
350 -// 弹窗打开时自动生成海报图片 346 +/**
351 -watch(show_proxy, (v) => { 347 + * @function init_once
352 - if (v) { 348 + * @description 组件挂载时生成一次海报,避免因弹框开关导致重复计算与变形。
349 + * @returns {void}
350 + */
351 +onMounted(() => {
352 + if (!poster_img_src.value) {
353 nextTick(() => compose_poster()) 353 nextTick(() => compose_poster())
354 - } else {
355 - poster_img_src.value = ''
356 } 354 }
357 }) 355 })
356 +
357 +/**
358 + * @function recompose_on_data_change
359 + * @description 仅当课程数据或二维码地址发生变化时重新合成海报;避免因弹窗显隐导致的重复计算。
360 + * @returns {void}
361 + */
362 +watch(() => [props.course, props.qr_url], () => {
363 + nextTick(() => compose_poster())
364 +}, { deep: false })
358 </script> 365 </script>
359 366
360 <style lang="less" scoped> 367 <style lang="less" scoped>
......
...@@ -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">
......