SharePoster.vue 14.7 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-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>
                </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, onMounted } 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
 * @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}`
        }
    } 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 || ''))

/**
 * @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 : '')
    return build_qr_service_url(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 合成海报:底部仅渲染标题与副标题,右侧居中排版;生成 dataURL 供长按保存。
 * @returns {Promise<void>}
 */
async function compose_poster() {
    poster_img_src.value = ''
    try {
        // 固定设计宽度与纵横比,避免因容器导致放大模糊
        const width = 750
        const aspect_ratio = 1 // 高度 = 宽度 * 1
        const height = Math.round(width * aspect_ratio)
        // 调整封面与信息区比例:封面 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 = Math.floor(Math.min(196, Math.max(96, info_body_h - 4)))

        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, 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)
        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 + text_offset_y
        ctx.fillStyle = '#1f2937' // gray-800
        ctx.font = title_font
        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 + 24); ty += 32 })

        // 生成 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 = ''
    }
}

/**
 * @function init_once
 * @description 组件挂载时生成一次海报,避免因弹框开关导致重复计算与变形。
 * @returns {void}
 */
onMounted(() => {
    if (!poster_img_src.value) {
        nextTick(() => compose_poster())
    }
})

/**
 * @function recompose_on_data_change
 * @description 仅当课程数据或二维码地址发生变化时重新合成海报;避免因弹窗显隐导致的重复计算。
 * @returns {void}
 */
watch(() => [props.course, props.qr_url], () => {
    nextTick(() => compose_poster())
}, { deep: false })
</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>