Skip to content

多端生成分享海报

约 2137 字大约 7 分钟

微信小程序imgjavascript

2024-12-26

微信小程序 / H5 / PC 三端生成海报

1. 微信小程序生成分享海报(朋友圈 QQ)

点我查看代码
<!--
 * Love and Peace
 * Description:
 * Components: 微信分享海报组件
 * URL:
 * NOTE:
    * 组件在最下方隐藏生成(因为生成过程和最后的分享调起分享弹窗会双层弹窗,看着很跳)
    * 这里用的是之前的比例 可以直接使用,如果尺寸不一致可以再加参数进行微调

    example:
     <WxSavePoster v-if="props.isWeapp && visible" poster-image-url="https://static.nowcoder.com/fe/file/oss/1716176092033TRSDY.png" :qrcode-image-config="qrcodeImageConfig" @close="close"></WxSavePoster>
-->

<template>
    <div class="save-image-popup">
        <div class="poster-main" :style="{width: `${canvasBoxStyle.width}px`, height: `${canvasBoxStyle.height}px`}">
            <canvas id="saveImageCanvas" canvas-id="saveImageCanvas" type="2d" width="100%" height="100%"></canvas>
        </div>
    </div>
</template>

<script lang="ts" setup>
import Taro from '@tarojs/taro';
import {onMounted, reactive} from 'vue';

const emits = defineEmits(['close']);
const props = defineProps({
    posterImageUrl: {type: String, required: true},

    // qrcodeImageConfig example:
    // const qrcodeImageConfig = ref({
    //     url: 'https://local.nowcoder.com/static/school-logo.jpeg',
    //     width: 80,
    //     height: 80,
    //     leftSkew: 0, // 左侧偏移 (用于矫正二维码位置)
    //     topSkew: 0 // 右侧偏移 (用于矫正二维码位置)
    // });
    qrcodeImageConfig: {type: Object, required: true},
    shareDone: {type: Function, required: true}
});

let radio = 0;
let ctx: any = null;
let canvas: any = null;
const canvasBoxStyle: any = reactive({width: 0, height: 0});

const initImage = () => {
    Taro.showLoading({title: '生成海报中...'});

    wx.createSelectorQuery()
        .select('#saveImageCanvas')
        .fields({node: true, size: true})
        .exec(async res => {
            const target = res[0];
            canvas = target.node;
            ctx = canvas.getContext('2d');

            // 调整 Canvas 分辨率: 确保 Canvas 的宽度和高度足够大,以保证图片的清晰度。可以将 Canvas 的尺寸设置为所需输出尺寸的两倍或三倍。
            const dpr = wx.getSystemInfoSync().pixelRatio;
            canvas.width = target.width * dpr;
            canvas.height = target.height * dpr;
            ctx.scale(dpr, dpr);

            await drawIMG(
                // 画背景图
                props.posterImageUrl,
                0,
                0,
                canvasBoxStyle.width,
                canvasBoxStyle.height
            );

            // 计算二维码位置
            const _qr = props.qrcodeImageConfig;
            const qrStyle = {
                left: (237 - _qr.leftSkew || 0) * (canvasBoxStyle.width / 344),
                top: (549 - _qr.topSkew || 0) * (canvasBoxStyle.height / 656)
            };
            radio = canvasBoxStyle.width / 344;

            await drawIMG(
                // 画二维码
                _qr.url,
                qrStyle.left,
                qrStyle.top,
                _qr.height * radio,
                _qr.width * radio
            );

            // 保存临时文件,并调起分享
            saveImageAndOpenWxShare();
        });
};

function drawIMG(src, x, y, width, height) {
    return new Promise(resolve => {
        let img = canvas.createImage();
        img.src = src;
        // 这个模拟器好用,但是真机不能➕ 加了报错
        // img.width = width;
        // img.height = height;
        img.onload = () => {
            ctx.drawImage(img, x, y, width, height);
            resolve(`[${src} ok]`);
        };
    });
}

const saveImageAndOpenWxShare = () => {
    wx.canvasToTempFilePath({
        canvas: canvas,
        success(res) {
            Taro.hideLoading();
            console.log(`[canvasToTempFilePath_success_res]`, res);
            wx.showShareImageMenu({
                path: res.tempFilePath,
                success(result) {
                    props.shareDone({shareType: '小程序分享'});
                    console.log(`[saveImageAndOpenWxShare_success_result]`, result);
                },
                fail(result) {
                    console.log(`[saveImageAndOpenWxShare_fail_result]`, result);
                }
            });
            emits('close');
        }
    });
};

onMounted(() => {
    const SystemInfoSync = Taro.getSystemInfoSync();
    const windowHeight = SystemInfoSync.windowHeight;
    // const canvasHeight = windowHeight - 188 - 23 - 14;
    const canvasHeight = windowHeight - 200;
    const canvasWidth = canvasHeight * (344 / 656);

    canvasBoxStyle.height = canvasHeight;
    canvasBoxStyle.width = canvasWidth;

    setTimeout(() => {
        initImage();
    }, 1000);
});

const close = () => emits('close');

defineExpose({close});
</script>

<style lang="scss">
.save-image-popup {
    // 一下属性是为了隐藏升成过程
    height: 0px;
    width: 0px;
    overflow: hidden;
    position: absolute;
    top: 2000px;
    left: 2000px;
    .poster-main {
        margin: 23px auto 14px auto;
    }
    .btn-box {
        .top-btn {
            height: 120px;
            margin: 0 12px;
            border-radius: 12px;
            background: white;
            display: flex;
            justify-content: space-around;
            align-items: center;
            .text {
                font-size: 12px;
                color: #555555;
                line-height: 12px;
                margin-top: 12px;
            }
        }
        .bottom-btn {
            height: 48px;
            line-height: 48px;
            background: #ffffff;
            border-radius: 12px;
            margin: 0 12px;
            margin-top: 8px;
            text-align: center;
            color: #a5a5a5;
            font-size: 16px;
        }
    }
}
</style>

2. 站内升成分享海报并调用hybird分享图片

点我查看代码
<!--
 * Love and Peace
 * Description:
 * Components: H5分享海报组件
 * URL:
 * NOTE:
-->

<template>
    <div class="h5-save-image-popup">
        <div class="poster-main" :style="{width: `${canvasBoxStyle.width}px`, height: `${canvasBoxStyle.height}px`}">
            <Canvas id="saveImageCanvas" canvas-id="saveImageCanvas" type="2d" style="height: 100%; width: 100%"></Canvas>
        </div>
    </div>
</template>

<script lang="ts" setup>
import Taro from '@tarojs/taro';
import {Toast} from '@fe/sdk-hybrid';
import {onMounted, reactive} from 'vue';
import {VCShareImage} from '@/utils/sdk';
import {postUploadImage} from '@/axios/common';

const emits = defineEmits(['close']);
const props = defineProps({
    posterImageUrl: {type: String, required: true},

    // qrcodeImageConfig example:
    // const qrcodeImageConfig = ref({
    //     url: 'https://local.nowcoder.com/static/school-logo.jpeg',
    //     width: 80,
    //     height: 80,
    //     leftSkew: 0, // 左侧偏移 (用于矫正二维码位置)
    //     topSkew: 0 // 右侧偏移 (用于矫正二维码位置)
    // });
    qrcodeImageConfig: {type: Object, required: true},
    shareDone: {type: Function, required: true}
});

let radio = 0;
let ctx: any = null;
let canvas: any = null;
const canvasBoxStyle: any = reactive({width: 0, height: 0});

const initImage = async () => {
    Taro.showLoading({title: '生成海报中...'});

    canvas = document.querySelector('#saveImageCanvas');
    ctx = canvas.getContext('2d');
    canvas.width = canvasBoxStyle.width;
    canvas.height = canvasBoxStyle.height;

    // 调整 Canvas 分辨率: 确保 Canvas 的宽度和高度足够大,以保证图片的清晰度。可以将 Canvas 的尺寸设置为所需输出尺寸的两倍或三倍。
    const dpr = window.devicePixelRatio;
    canvas.width = canvas.width * dpr;
    canvas.height = canvas.height * dpr;
    ctx.scale(dpr, dpr);

    await drawIMG(
        // 画背景图
        props.posterImageUrl,
        0,
        0,
        canvasBoxStyle.width,
        canvasBoxStyle.height
    );

    // 计算二维码位置
    const _qr = props.qrcodeImageConfig;
    const qrStyle = {
        left: (237 - _qr.leftSkew || 0) * (canvasBoxStyle.width / 344),
        top: (549 - _qr.topSkew || 0) * (canvasBoxStyle.height / 656)
    };
    radio = canvasBoxStyle.width / 344;

    await drawIMG(
        // 画二维码
        _qr.url,
        qrStyle.left,
        qrStyle.top,
        _qr.height * radio,
        _qr.width * radio
    );

    // 升成图片并上传到oss
    const blob = await new Promise(resolve => {
        canvas.toBlob(resolve, 'image/png');
    });
    const data = new FormData();
    data.append('file', blob as Blob, 'oln-share.png');
    const res: any = await postUploadImage({body: data});

    if (!res.url) {
        Taro.showToast({title: '上传图片失败', icon: 'error'});
        return;
    }

    Taro.hideLoading();
    emits('close');

    // 调起站内分享
    VCShareImage({
        src: res.url,
        gioExtra: {source_var: '2024老带新'},
        call(callData) {
            console.log(`[Share.image callData]`, callData);
            // 这里看文档之前传的是 boolean 类型,现在改成了 object 类型,所以这里需要判断一下
            // 在兼容一下 ios 安卓 数据结构层级问题
            const isSuccess = typeof callData === 'boolean' ? callData : callData.isSuccess ?? callData.result?.isSuccess ?? callData.data?.result?.isSuccess ?? false;
            if (isSuccess) {
                Toast.success('分享成功');
                props.shareDone({shareType: `app${callData.media || '分享'}`});
            } else {
                Toast.error('分享失败');
            }
        }
    });
};

function drawIMG(src, x, y, width, height) {
    return new Promise(resolve => {
        let img = new Image();
        img.src = src;
        img.width = width;
        img.height = height;
        img.crossOrigin = 'anonymous';
        img.onload = () => {
            ctx.drawImage(img, x, y, width, height);
            resolve(`[${src} ok]`);
        };
    });
}

onMounted(() => {
    const SystemInfoSync = Taro.getSystemInfoSync();
    const windowHeight = SystemInfoSync.windowHeight;
    // const canvasHeight = windowHeight - 188 - 23 - 14;
    const canvasHeight = windowHeight - 200;
    const canvasWidth = canvasHeight * (344 / 656);

    canvasBoxStyle.height = canvasHeight;
    canvasBoxStyle.width = canvasWidth;

    setTimeout(() => {
        initImage();
    }, 1000);
});

const close = () => emits('close');

defineExpose({close});
</script>

<style lang="scss">
.h5-save-image-popup {
    .poster-main {
        margin: 23px auto 14px auto;
    }
    .btn-box {
        .top-btn {
            height: 120px;
            margin: 0 12px;
            border-radius: 12px;
            background: white;
            display: flex;
            justify-content: space-around;
            align-items: center;
            .text {
                font-size: 12px;
                color: #555555;
                line-height: 12px;
                margin-top: 12px;
            }
        }
        .bottom-btn {
            height: 48px;
            line-height: 48px;
            background: #ffffff;
            border-radius: 12px;
            margin: 0 12px;
            margin-top: 8px;
            text-align: center;
            color: #a5a5a5;
            font-size: 16px;
        }
    }
}
</style>

3. PC 生成海报并复制or下载

点我查看代码
<!--
 * Love and Peace
 * Description:
 * Components: pc 分享海报组件
 * URL:
 * NOTE:
-->

<template>
    <div class="pc-save-image-popup">
        <div style="height: 23px"></div>
        <div class="poster-main-box">
            <div class="poster-main" :style="{width: `${canvasBoxStyle.width}px`, height: `${canvasBoxStyle.height}px`}">
                <Canvas id="saveImageCanvas" canvas-id="saveImageCanvas" type="2d" style="height: 100%; width: 100%"></Canvas>
            </div>
        </div>
        <div class="btn-box">
            <div class="top-btn">
                <div class="tw-flex tw-flex-col tw-items-center tw-cursor-pointer" @click="copyPoster">
                    <div class="icon-box">
                        <IconZhaopingoutong size="30"></IconZhaopingoutong>
                    </div>
                    <div class="text">复制海报图片</div>
                </div>
                <div class="tw-flex tw-flex-col tw-items-center tw-cursor-pointer" @click="downloadPoster">
                    <div class="icon-box">
                        <IconXiazai size="30"></IconXiazai>
                    </div>
                    <div class="text">下载海报图片</div>
                </div>
            </div>
            <div class="bottom-btn tw-cursor-pointer" @click="close">取消</div>
        </div>
    </div>
</template>

<script lang="ts" setup>
import Taro from '@tarojs/taro';
import {onMounted, reactive} from 'vue';
import {IconZhaopingoutong} from '@ncfe/nc.icon.nowpick';
import {IconXiazai} from '@ncfe/nc.icon.sparta';

const emits = defineEmits(['close']);
const props = defineProps({
    posterImageUrl: {type: String, required: true},

    // qrcodeImageConfig example:
    // const qrcodeImageConfig = ref({
    //     url: 'https://local.nowcoder.com/static/school-logo.jpeg',
    //     width: 80,
    //     height: 80,
    //     leftSkew: 0, // 左侧偏移 (用于矫正二维码位置)
    //     topSkew: 0 // 右侧偏移 (用于矫正二维码位置)
    // });
    qrcodeImageConfig: {type: Object, required: true},
    shareDone: {type: Function, required: true}
});

let radio = 0;
let ctx: any = null;
let canvas: any = null;
const canvasBoxStyle: any = reactive({width: 0, height: 0});

const initImage = async () => {
    canvas = document.querySelector('#saveImageCanvas');
    ctx = canvas.getContext('2d');
    canvas.width = canvasBoxStyle.width;
    canvas.height = canvasBoxStyle.height;

    // 调整 Canvas 分辨率: 确保 Canvas 的宽度和高度足够大,以保证图片的清晰度。可以将 Canvas 的尺寸设置为所需输出尺寸的两倍或三倍。
    const dpr = window.devicePixelRatio;
    console.log(`[dpr]`, dpr);
    canvas.width = canvas.width * dpr;
    canvas.height = canvas.height * dpr;
    ctx.scale(dpr, dpr);

    await drawIMG(
        // 画背景图
        props.posterImageUrl,
        0,
        0,
        canvasBoxStyle.width,
        canvasBoxStyle.height
    );

    // 计算二维码位置
    const _qr = props.qrcodeImageConfig;
    const qrStyle = {
        left: (237 - _qr.leftSkew || 0) * (canvasBoxStyle.width / 344),
        top: (549 - _qr.topSkew || 0) * (canvasBoxStyle.height / 656)
    };
    radio = canvasBoxStyle.width / 344;

    await drawIMG(
        // 画二维码
        _qr.url,
        qrStyle.left,
        qrStyle.top,
        _qr.height * radio,
        _qr.width * radio
    );

    Taro.hideLoading();
};

const copyPoster = async () => {
    try {
        props.shareDone({shareType: 'pc复制海报图片'});
        // 将Canvas转换为Blob对象
        const dataURL = canvas.toDataURL('image/png'); // 转换为dataURL
        const blob = await (await fetch(dataURL)).blob(); // 将dataURL转为Blob

        // 创建一个 ClipboardItem 对象
        const item = new ClipboardItem({'image/png': blob});

        // 复制到剪贴板
        await navigator.clipboard.write([item]);
        Taro.showToast({title: '复制成功', icon: 'success'});
        emits('close');
    } catch (err) {
        console.error('复制失败:', err);
    }
};

const downloadPoster = () => {
    props.shareDone({shareType: 'pc下载海报图片'});
    // 使用toDataURL方法将Canvas内容转换为dataURL
    const dataURL = canvas.toDataURL('image/png'); // 默认为png格式,也可以指定其他格式如 "image/jpeg"

    // 创建隐藏的可下载链接
    const downloadLink = document.createElement('a');
    downloadLink.href = dataURL;
    downloadLink.download = 'share-image.png'; // 设置下载文件名

    // 触发点击事件以开始下载
    document.body.appendChild(downloadLink);
    downloadLink.click();

    // 下载后移除链接,防止页面上残留多个隐藏的链接
    document.body.removeChild(downloadLink);
    emits('close');
};

function drawIMG(src, x, y, width, height) {
    return new Promise(resolve => {
        let img = new Image();
        img.src = src;
        img.width = width;
        img.height = height;
        img.crossOrigin = 'anonymous';
        img.onload = () => {
            ctx.drawImage(img, x, y, width, height);
            resolve(`[${src} ok]`);
        };
    });
}

onMounted(() => {
    const SystemInfoSync = Taro.getSystemInfoSync();
    const windowHeight = SystemInfoSync.windowHeight;
    // const canvasHeight = windowHeight - 188 - 23 - 14;
    const canvasHeight = windowHeight - 240;
    const canvasWidth = canvasHeight * (344 / 656);

    canvasBoxStyle.height = canvasHeight;
    canvasBoxStyle.width = canvasWidth;

    Taro.showLoading({title: '生成海报中...'});
    setTimeout(() => {
        initImage();
    }, 500);
});

const close = () => emits('close');

defineExpose({close});
</script>

<style lang="scss">
.pc-save-image-popup {
    height: 100%;
    .poster-main-box {
        height: calc(100% - 205px);
    }
    .poster-main {
        margin: 0 auto;
    }
    .btn-box {
        .icon-box {
            width: 48px;
            height: 48px;
            background: #f9f9f9;
            border-radius: 24px;
            display: flex;
            justify-content: center;
            align-items: center;
        }
        .top-btn {
            height: 120px;
            margin: 0 12px;
            border-radius: 12px;
            background: white;
            display: flex;
            justify-content: space-around;
            align-items: center;
            .text {
                font-size: 12px;
                color: #555555;
                line-height: 12px;
                margin-top: 12px;
            }
        }
        .bottom-btn {
            height: 48px;
            line-height: 48px;
            background: #ffffff;
            border-radius: 12px;
            margin: 0 12px;
            margin-top: 8px;
            text-align: center;
            color: #a5a5a5;
            font-size: 16px;
        }
    }
}
</style>