SharePoster.vue 14.8 KB
<template>
    <!-- 弹窗容器:展示分享海报 -->
    <van-popup
        v-model:show="show_proxy"
        round
        position="bottom"
        :style="{ width: '100%' }"
    >
        <div class="PosterWrapper p-4">
            <!-- 标题与关闭按钮 -->
            <div class="flex justify-between items-center mb-3">
                <h3 class="font-medium">分享海报</h3>
                <van-icon name="cross" @click="close" />
            </div>

            <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 -->
            <div class="PosterCard bg-white rounded-xl shadow-md overflow-hidden mx-auto" ref="card_ref">
                <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" />
                <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) -->
                <div v-else>
                    <!-- 上部封面图 -->
                    <div class="PosterCover">
                        <img :src="cover_src" alt="课程封面" class="w-full h-full object-cover" crossorigin="anonymous" />
                    </div>
                    <!-- 下部信息区:左二维码 + 右文案 -->
                    <div class="PosterInfo p-4">
                        <div class="flex items-start">
                            <!-- 左侧二维码 -->
                            <div class="PosterQR mr-4">
                                <img :src="qr_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" />
                            </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>
                        </div>
                    </div>
                </div>
            </div>

            <!-- 底部提示与关闭按钮 -->
            <div class="mt-3 text-center text-gray-500 text-xs">长按图片保存至手机</div>
            <div class="mt-4">
                <button class="w-full bg-white border border-green-500 text-green-600 py-2 rounded-lg" @click="close">关闭</button>
            </div>
        </div>
    </van-popup>
    <van-back-top right="5vw" bottom="25vh" offset="600" />
</template>

<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import QRCode from 'qrcode'
import { showToast } from 'vant'

/**
 * @typedef {Object} CourseLike
 * @property {string} [title] 课程标题
 * @property {string} [subtitle] 课程副标题
 * @property {string} [cover] 课程封面地址
 * @property {string} [introduce] 课程介绍(可包含HTML)
 * @property {string} [start_at] 开始日期(可选)
 * @property {string} [end_at] 结束日期(可选)
 */

/**
 * @function normalize_image_url
 * @description 若图片域名为 `cdn.ipadbiz.cn`,追加压缩参数 `?imageMogr2/thumbnail/200x/strip/quality/70`。
 * @param {string} src 原始图片地址
 * @returns {string} 处理后的图片地址
 */
function normalize_image_url(src) {
    const url = src || ''
    if (!url) return ''
    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`
        }
    } catch (e) {
        // 非绝对路径或无法解析的场景,直接返回原值
    }
    return url
}

const props = defineProps({
    /** 弹窗显隐(v-model:show) */
    show: { type: Boolean, default: false },
    /** 课程对象(动态生成海报所需信息) */
    course: { type: Object, default: () => ({}) },
    /** 二维码跳转地址(默认当前页面URL) */
    qr_url: { type: String, default: '' }
})

const emit = defineEmits(['update:show'])

/**
 * @function close
 * @description 关闭弹窗
 * @returns {void}
 */
const close = () => {
    emit('update:show', false)
}

/**
 * @function show_proxy
 * @description 将 `props.show` 映射为可写的计算属性以支持 v-model:show
 */
const show_proxy = computed({
    get() { return props.show },
    set(v) { emit('update:show', v) }
})

/**
 * @var {import('vue').Ref<HTMLElement|null>} card_ref
 * @description 海报卡片容器引用,用于动态获取容器宽度以计算 Canvas 尺寸
 */
const card_ref = ref(null)

/** 标题/副标题/介绍 */
const title_text = computed(() => props.course?.title || '课程')
const subtitle_text = computed(() => props.course?.subtitle || '')

/**
 * @function strip_html
 * @description 将富文本介绍转换为纯文本并截断展示。
 * @param {string} html 原始HTML
 * @returns {string} 纯文本
 */
function strip_html(html) {
    return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}

const intro_text = computed(() => {
    const raw = props.course?.introduce || ''
    const text = strip_html(raw)
    return text
})

/** 日期范围(若有) */
const date_range_text = computed(() => {
    const s = props.course?.start_at || ''
    const e = props.course?.end_at || ''
    if (s && e) return `${s}  ${e}`
    return ''
})

/** 封面图地址(含CDN压缩规则) */
const cover_src = computed(() => normalize_image_url(props.course?.cover || ''))

/** 二维码图片(使用在线服务生成) */
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)}`
})

// 海报图片 dataURL(用于长按保存)
const poster_img_src = ref('')

/**
 * @function load_image
 * @description 以匿名跨域方式加载图片并追加时间戳防缓存;失败返回 null。
 * @param {string} src 图片地址
 * @returns {Promise<HTMLImageElement|null>} 加载成功的图片对象或 null
 */
function load_image(src) {
    return new Promise((resolve) => {
        if (!src) return resolve(null)
        const img = new Image()
        img.crossOrigin = 'anonymous'
        const with_ts = src + (src.includes('?') ? '&' : '?') + 'v=' + Date.now()
        img.onload = () => resolve(img)
        img.onerror = () => resolve(null)
        img.src = with_ts
    })
}

/**
 * @function wrap_text
 * @description 按最大宽度自动换行并限制最大行数;溢出末行追加省略号。
 * @param {CanvasRenderingContext2D} ctx 画布上下文
 * @param {string} text 文本内容
 * @param {number} max_w 最大宽度
 * @param {string} font 字体样式(如 'bold 32px sans-serif')
 * @param {number} line_h 行高
 * @param {number} max_lines 最大行数
 * @returns {string[]} 处理后的行数组
 */
function wrap_text(ctx, text, max_w, font, line_h, max_lines) {
    ctx.font = font
    const content = (text || '').trim()
    if (!content) return []
    const lines = []
    let cur = ''
    const tokens = Array.from(content)
    tokens.forEach(ch => {
        const test = cur + ch
        if (ctx.measureText(test).width <= max_w) {
            cur = test
        } else {
            lines.push(cur)
            cur = ch
        }
    })
    if (cur) lines.push(cur)
    if (lines.length > max_lines) {
        const truncated = lines.slice(0, max_lines)
        const last = truncated[truncated.length - 1]
        truncated[truncated.length - 1] = (last || '').slice(0, Math.max(0, (last || '').length - 1)) + '…'
        return truncated
    }
    return lines
}

/**
 * @function compose_poster
 * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。
 * @returns {Promise<void>}
 */
/**
 * @function compose_poster
 * @description 以 Canvas 合成海报:根据容器宽度动态计算尺寸;封面高度为底部信息区的 3 倍(整体 3:1 比例),生成 dataURL 供长按保存。
 * @returns {Promise<void>}
 */
async function compose_poster() {
    poster_img_src.value = ''
    try {
        // 固定设计宽度与纵横比,避免因容器导致放大模糊
        const width = 750
        const aspect_ratio = 1.6 // 高度 = 宽度 * 1.6(稳定的长方形比例)
        const height = Math.round(width * aspect_ratio)
        const cover_h = Math.round(height * 3 / 4)
        const info_h = height - cover_h
        const padding = 32
        const info_body_h = info_h - padding * 2
        const qr_size = 196

        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 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 dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
        const canvas = document.createElement('canvas')
        canvas.width = Math.round(width * dpr)
        canvas.height = Math.round(height * dpr)
        const ctx = canvas.getContext('2d')
        ctx.scale(dpr, dpr)

        // 背景
        ctx.fillStyle = '#ffffff'
        ctx.fillRect(0, 0, width, height)

        // 封面图(对象填充)
        const cover_img = await load_image(cover_src.value)
        if (cover_img) {
            const scale = Math.max(width / cover_img.width, cover_h / cover_img.height)
            const dw = cover_img.width * scale
            const dh = cover_img.height * scale
            const dx = (width - dw) / 2
            const dy = (cover_h - dh) / 2
            ctx.drawImage(cover_img, dx, dy, dw, dh)
        } else {
            ctx.fillStyle = '#f3f4f6' // gray-100
            ctx.fillRect(0, 0, width, cover_h)
            ctx.fillStyle = '#9ca3af' // gray-400
            ctx.textAlign = 'center'
            ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"'
            ctx.fillText('封面加载失败', width / 2, cover_h / 2 + 10)
            ctx.textAlign = 'left'
        }

        // 二维码(本地生成避免跨域)
        const qr_canvas = document.createElement('canvas')
        qr_canvas.width = Math.round(qr_size * dpr)
        qr_canvas.height = Math.round(qr_size * dpr)
        const qr_url_val = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '')
        try {
            await QRCode.toCanvas(qr_canvas, qr_url_val, { width: Math.round(qr_size * dpr), margin: Math.round(2 * dpr), color: { dark: '#000000', light: '#ffffff' } })
        } catch (e) {
            const qctx = qr_canvas.getContext('2d')
            qctx.fillStyle = '#ffffff'
            qctx.fillRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr))
            qctx.strokeStyle = '#e5e7eb'
            qctx.strokeRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr))
            qctx.fillStyle = '#9ca3af'
            qctx.font = `${Math.round(16 * dpr)}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"`
            qctx.fillText('二维码生成失败', Math.round(12 * dpr), Math.round((qr_size * dpr) / 2))
        }
        ctx.drawImage(qr_canvas, padding, cover_h + padding, qr_size, qr_size)

        // 文案(右侧)
        let tx = padding + qr_size + 20
        let ty = cover_h + padding
        ctx.fillStyle = '#1f2937' // gray-800
        ctx.font = title_font
        title_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 })

        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 })

        // 生成 dataURL
        try {
            const data_url = canvas.toDataURL('image/png')
            poster_img_src.value = data_url
        } catch (e) {
            console.error('海报生成失败(跨域):', e)
            showToast('海报生成失败,已展示标准卡片,请长按保存截图')
            poster_img_src.value = ''
        }
    } catch (err) {
        console.error('compose_poster 异常:', err)
        showToast('海报生成失败,请稍后重试')
        poster_img_src.value = ''
    }
}

// 弹窗打开时自动生成海报图片
watch(show_proxy, (v) => {
    if (v) {
        nextTick(() => compose_poster())
    } else {
        poster_img_src.value = ''
    }
})
</script>

<style lang="less" scoped>
.PosterWrapper {
    height: auto;
    display: flex;
    flex-direction: column;
    .PosterCard {
        width: 100%;
        max-width: 750px;
        height: auto;
        margin: 0 auto;
        display: block;
        > img {
            width: 100%;
            height: auto;
            object-fit: contain;
            display: block;
        }
        .PosterInfo {
            overflow: hidden;
            .PosterQR {
                img {
                    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
                }
            }
        }
    }
}
</style>