hookehuyr

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

重构海报生成组件,简化文案显示仅保留标题和副标题
使用项目内部接口生成二维码替代第三方服务
调整海报布局比例和字体大小以提升视觉效果
移除图片压缩参数并优化组件初始化逻辑
......@@ -31,10 +31,8 @@
</div>
<!-- 右侧文案 -->
<div class="flex-1">
<div class="text-base font-semibold text-gray-800 truncate">{{ title_text }}</div>
<div class="text-xs text-gray-500 mt-1 truncate" v-if="subtitle_text">{{ subtitle_text }}</div>
<div class="text-sm text-gray-700 mt-2 leading-6 line-clamp-3" v-if="intro_text">{{ intro_text }}</div>
<div class="text-xs text-gray-400 mt-2" v-if="date_range_text">{{ date_range_text }}</div>
<div class="text-lg font-semibold text-gray-800 truncate">{{ title_text }}</div>
<div class="text-sm text-gray-500 mt-1 truncate" v-if="subtitle_text">{{ subtitle_text }}</div>
</div>
</div>
</div>
......@@ -52,7 +50,7 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import QRCode from 'qrcode'
import { showToast } from 'vant'
......@@ -68,7 +66,6 @@ import { showToast } from 'vant'
/**
* @function normalize_image_url
* @description 若图片域名为 `cdn.ipadbiz.cn`,追加压缩参数 `?imageMogr2/thumbnail/200x/strip/quality/70`。
* @param {string} src 原始图片地址
* @returns {string} 处理后的图片地址
*/
......@@ -78,7 +75,7 @@ function normalize_image_url(src) {
try {
const u = new URL(url, window.location.origin)
if (u.hostname === 'cdn.ipadbiz.cn' && !u.search) {
return `${url}?imageMogr2/thumbnail/200x/strip/quality/70`
return `${url}`
}
} catch (e) {
// 非绝对路径或无法解析的场景,直接返回原值
......@@ -152,11 +149,27 @@ const date_range_text = computed(() => {
/** 封面图地址(含CDN压缩规则) */
const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))
/** 二维码图片(使用在线服务生成) */
/**
* @function build_qr_service_url
* @description 按项目接口规则拼接二维码服务地址:`origin/admin/?m=srv&a=get_qrcode&key=实际地址`
* @param {string} raw_url 实际跳转地址
* @returns {string} 可直接用于 img 的二维码服务地址
*/
function build_qr_service_url(raw_url) {
const origin = import.meta.env.VITE_PROXY_TARGET || (typeof window !== 'undefined' ? window.location.origin : '')
// 规整 origin,确保以 / 结尾便于 URL 组合
const base = origin.endsWith('/') ? origin : origin + '/'
const api = new URL('admin/?m=srv&a=get_qrcode', base)
const key = encodeURIComponent(raw_url || '')
api.searchParams.set('key', raw_url ? raw_url : '')
// 某些服务不识别 searchParams 的编码方式,这里直接拼接编码后的 key 以确保兼容
return `${api.origin}${api.pathname}?m=srv&a=get_qrcode&key=${key}`
}
/** 二维码图片(通过项目接口获取) */
const qr_src = computed(() => {
const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
const size = '180x180'
return `https://api.qrserver.com/v1/create-qr-code/?size=${size}&data=${encodeURIComponent(url)}`
return build_qr_service_url(url)
})
// 海报图片 dataURL(用于长按保存)
......@@ -224,7 +237,7 @@ function wrap_text(ctx, text, max_w, font, line_h, max_lines) {
*/
/**
* @function compose_poster
* @description 以 Canvas 合成海报:根据容器宽度动态计算尺寸;封面高度为底部信息区的 3 倍(整体 3:1 比例),生成 dataURL 供长按保存。
* @description 以 Canvas 合成海报:底部仅渲染标题与副标题,右侧居中排版;生成 dataURL 供长按保存。
* @returns {Promise<void>}
*/
async function compose_poster() {
......@@ -232,37 +245,28 @@ async function compose_poster() {
try {
// 固定设计宽度与纵横比,避免因容器导致放大模糊
const width = 750
const aspect_ratio = 1.6 // 高度 = 宽度 * 1.6(稳定的长方形比例)
const aspect_ratio = 1 // 高度 = 宽度 * 1
const height = Math.round(width * aspect_ratio)
const cover_h = Math.round(height * 3 / 4)
// 调整封面与信息区比例:封面 2/3,高度留出更多信息区避免裁切
const cover_h = Math.round(height * 2 / 3)
const info_h = height - cover_h
const padding = 32
const info_body_h = info_h - padding * 2
const qr_size = 196
// 二维码尺寸不超过信息区有效高度,保留少量安全边距,防止底部被裁切
const qr_size = Math.floor(Math.min(196, Math.max(96, info_body_h - 4)))
const title_font = 'bold 32px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const subtitle_font = 'normal 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const intro_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const date_font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
const measurer = document.createElement('canvas')
const mctx = measurer.getContext('2d')
const text_max_w = width - padding * 2 - qr_size - 20
// 先测量固定行数的标题/副标题/日期
const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 40, 1)
const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 30, 1) : []
const date_lines = date_range_text.value ? wrap_text(mctx, date_range_text.value, text_max_w, date_font, 28, 1) : []
const reserved_h = (title_lines.length ? title_lines.length * 40 : 0)
+ (subtitle_lines.length ? 30 : 0)
+ (date_lines.length ? 28 + 8 : 0)
// 动态计算介绍文本的最大行数以适配信息区高度
const intro_line_h = 34
const intro_space = Math.max(0, info_body_h - reserved_h - 8)
const max_intro_lines = Math.max(0, Math.floor(intro_space / intro_line_h))
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)) : []
// 仅测量标题与副标题,并在信息区垂直居中排版
const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 44, 1)
const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 32, 1) : []
const total_text_h = (title_lines.length ? 44 : 0) + (subtitle_lines.length ? 32 : 0)
const text_offset_y = Math.max(0, Math.floor((info_body_h - total_text_h) / 2))
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
const canvas = document.createElement('canvas')
canvas.width = Math.round(width * dpr)
......@@ -312,24 +316,16 @@ async function compose_poster() {
}
ctx.drawImage(qr_canvas, padding, cover_h + padding, qr_size, qr_size)
// 文案(右侧)
// 文案(右侧,仅标题 + 副标题,整体居中
let tx = padding + qr_size + 20
let ty = cover_h + padding
let ty = cover_h + padding + text_offset_y
ctx.fillStyle = '#1f2937' // gray-800
ctx.font = title_font
title_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 })
title_lines.forEach(line => { ctx.fillText(line, tx, ty + 34); ty += 44 })
ctx.fillStyle = '#6b7280' // gray-500
ctx.font = subtitle_font
subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 18); ty += 30 })
ctx.fillStyle = '#374151' // gray-700
ctx.font = intro_font
intro_lines.forEach(line => { ctx.fillText(line, tx, ty + 22); ty += 34 })
ctx.fillStyle = '#9ca3af' // gray-400
ctx.font = date_font
date_lines.forEach(line => { ctx.fillText(line, tx, ty + 16); ty += 28 })
subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 24); ty += 32 })
// 生成 dataURL
try {
......@@ -347,14 +343,25 @@ async function compose_poster() {
}
}
// 弹窗打开时自动生成海报图片
watch(show_proxy, (v) => {
if (v) {
/**
* @function init_once
* @description 组件挂载时生成一次海报,避免因弹框开关导致重复计算与变形。
* @returns {void}
*/
onMounted(() => {
if (!poster_img_src.value) {
nextTick(() => compose_poster())
} else {
poster_img_src.value = ''
}
})
/**
* @function recompose_on_data_change
* @description 仅当课程数据或二维码地址发生变化时重新合成海报;避免因弹窗显隐导致的重复计算。
* @returns {void}
*/
watch(() => [props.course, props.qr_url], () => {
nextTick(() => compose_poster())
}, { deep: false })
</script>
<style lang="less" scoped>
......
......@@ -224,7 +224,7 @@
</svg>
咨询
</button>
<!-- <button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster">
<button class="flex flex-col items-center text-gray-500 text-xs" @click="open_share_poster">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
......@@ -240,7 +240,7 @@
/>
</svg>
分享
</button> -->
</button>
</div>
<div class="flex items-center">
<div v-if="!course?.is_buy" class="mr-2">
......