feat(SharePoster): 优化海报生成逻辑并启用分享按钮
重构海报生成组件,简化文案显示仅保留标题和副标题 使用项目内部接口生成二维码替代第三方服务 调整海报布局比例和字体大小以提升视觉效果 移除图片压缩参数并优化组件初始化逻辑
Showing
2 changed files
with
58 additions
and
51 deletions
| ... | @@ -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"> | ... | ... |
-
Please register or login to post a comment