uniapp+Vue3:生成动态二维码海报—扫码直达任意链接

702 阅读7分钟

1.需求介绍

  • 本系统的邀请地址放在二维码中,被邀请人扫描后可注册为其团队。
  • 背景图中会有一处正方形的空白,这个空白就是需要二维码来填充,然后合成邀请海报,并且转为图片提供给用户下载
  • 现在有多个背景图,需要作海报背景图的切换。

2.实现

2.1 二维码的生成

  • 借助的是lime-painter的组件,来绘制二维码。
  • 可以去uniapp插件市场安装,也可以使用npm安装。
<script setup>
const painterRef = ref(null); // 海报画板组件的实例对象
const showPainter = ref(true)
const painterImageUrl = ref(); // 海报 url

    
// 渲染函数
const renderPoster = async (poster) => {
  await painterRef.value.render(poster);
}
    
// 制作二维码
const markCode = (text) => {
  renderPoster({
    css: {
      width: `${state.qrcodeSize}px`,
      height: `${state.qrcodeSize}px`
    },
    views:[
      {
        type: 'qrcode',
        text: text,
        css: {
          width: `${state.qrcodeSize}px`,
          height: `${state.qrcodeSize}px`
        }
      }
    ]
  })
}

// 获得生成的图片
const setPainterImageUrl = (path) => {
  // 此时在这里,二维码已经被转换为图片了,这个图片的地址是浏览器临时地址。
  painterImageUrl.value = path;
  showPainter.value = false
  // 省略canvas渲染函数,下一部分贴全部代码。
};

// 复制邀请链接
// #ifdef H5
const onInviteRegister = () => {
  const rootUrl = getRootUrl(); // 获取H5跟地址
  const realUrl = rootUrl.includes('localhost') ? 'http://192.168.2.19:3000/' : rootUrl;
  // userInfo?.value.inviteCode 存储在pinia中的个人邀请码。
  return realUrl + 'pages/auth/register?inviteCode=' + userInfo?.value.inviteCode
}
// #endif

onMounted(async () => {
  const url = onInviteRegister()
  markCode(url)
})

/**
 * 获取H5-真实根地址 兼容hash+history模式
 */
export function getRootUrl() {
  let url = '';
  // #ifdef H5
  url = location.origin + '/';

  if (location.hash !== '') {
    url += '#/';
  }
  // #endif
  return url;
}
</script>

<template>
 <!--  海报画板:默认隐藏只用来生成海报。生成方式为主动调用  -->
 <l-painter
  v-if="showPainter"
  isCanvasToTempFilePath
  pathType="url"
  @success="setPainterImageUrl"
  hidden
  ref="painterRef"
/>
</template>

2.2 海报生成。

  • 利用的是 uni.createCanvasContextApi 和 canvas元素
  • 二维码合并在背景图中,然后在h5中下载这个邀请海报
<script setup>
// 海报尺寸比例
const posterRatio = 2000 / 1524

// 背景图大小
const posterSize = {
  width: 1524,
  height: 2000
}

const state = reactive({
  qrcodeSize: 630,
  // 二维码在海报中的位置比例
  qrcodePosition: {
    x: 447 / posterSize.width, // 水平位置比例
    y: 1221 / posterSize.height // 垂直位置比例
  }
})

const painterImageUrl = ref(); // 海报 url
const pixelRatio = ref(1)
const scale = ref(1) // 缩放比例

// 生成海报背景图
const drawPoster = async () => {
  if(!painterImageUrl.value) {
    return uni.showToast({
      title: '二维码生成中,请稍后...',
      icon: 'none'
    })
  }

  await uni.showLoading({
    title: '海报生成中...',
    mask: true
  })


  try {
    // 创建canvas上下文
    const ctx = uni.createCanvasContext("mycanvas")
    // 清空画布
    ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
    // 绘制背景图
    ctx.drawImage(currentPoster.value, 0, 0, canvasWidth.value, canvasHeight.value)
    // 计算二维码位置和大小
    const qrcodeSize = state.qrcodeSize * scale.value
    const qrcodeX = canvasWidth.value * state.qrcodePosition.x
    const qrcodeY = canvasHeight.value * state.qrcodePosition.y
    // 绘制二维码
    ctx.drawImage(painterImageUrl.value, qrcodeX, qrcodeY, qrcodeSize, qrcodeSize)
    // 执行绘制
    ctx.draw(false, () => {
      setTimeout(() => {
        // 将canvas转为图片
        uni.canvasToTempFilePath({
          canvasId: 'mycanvas',
          success: (res) => {
            isPosterShow.value = true
            // 保存临时路径用于分享或保存
            state.tempFilePath = res.tempFilePath
            uni.hideLoading()
          },
          fail: (err) => {
            console.error('Canvas转图片失败:', err)
            uni.hideLoading()
          }
        })
      }, 300)
    })
  } catch(e) {
    console.error('绘制海报失败:', error)
    uni.hideLoading()
  }
}

// 计算画布尺寸
const calculateCanvasSize = () => {
  const systemInfo = uni.getSystemInfoSync()
  const screenWidth = systemInfo.windowWidth
  // const screenHeight = systemInfo.windowHeight

  // 修改计算逻辑,保持原始比例
  // 使用固定比例,确保海报完整显示且不变形
  canvasWidth.value = screenWidth
  // canvasHeight.value = screenWidth * posterRatio
  canvasHeight.value = (screenWidth / 3) * 4 * posterRatio
  // 计算缩放比例
  scale.value = canvasWidth.value / posterSize.width

  // 设置像素比
  pixelRatio.value = systemInfo.pixelRatio || 1
}

// 保存图片方法
const saveImage = () => {
  // #ifdef H5
  uni.showLoading({
    title: '正在下载中...',
    mask: true
  })
  const xhr = new XMLHttpRequest();
  xhr.open('GET', state.tempFilePath, true);
  xhr.responseType = 'arraybuffer';    // 返回类型blob
  xhr.onload = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
      let blob = this.response;
      // 转换一个blob链接
      let u = window.URL.createObjectURL(new Blob([blob],{ type: 'image/png' })) // 视频的type是video/mp4,图片是image/jpeg
      let a = document.createElement('a');
      a.download = 'image'; // 设置下载的文件名
      a.href = u;
      a.style.display = 'none'
      document.body.appendChild(a)
      a.click();
      uni.hideLoading()
      document.body.removeChild(a);
    }
  };
  xhr.send()
  // #endif
};

onLoad(() => {
  calculateCanvasSize()
})
</script>
<template>
    <view>
        <view class="poster-wrapper">
          <canvas
            :disable-scroll="false"
            canvas-id="mycanvas"
            id="mycanvas"
            :style="{
              width: canvasWidth + 'px',
              height: canvasHeight + 'px'
            }"
          ></canvas>
        </view>
    
        <view class="button-wrapper">
          <button class="save-button" @click="saveImage">保存图片</button>
        </view>
    </view>
</template>

2.3 缩略图选择,更换海报背景图

  • 缩略图展示多个背景图,用户可以随意切换海报的背景图。
  • 缩略图默认隐藏,当点击顶部的文字时,则显示缩略图。
  • 点击缩略图以外的位置,则隐藏缩略图。
<script setup>
    // 如果缩略图太多的情况下,可以使用动态引入的方法,就不需要一个一个的引入了。
    import poster1 from '@/static/img/poster1.jpg'
    import poster2 from '@/static/img/poster2.jpg'
    import poster3 from '@/static/img/poster3.jpg'
    
    // 海报背景图列表
    const posterList = [
    // 如下每项的src属性这是背景图的地址,name属性则是缩略图的名称
      { id: 1, src: poster1, name: '背景1' }, 
      { id: 2, src: poster2, name: '背景2' },
      { id: 3, src: poster3, name: '背景3' },
    ]

    // 当前选中的背景图
    const currentPoster = ref(posterList[0].src)
    // 是否显示缩略图选择器
    const showThumbnails = ref(false)

    // 切换海报背景
    const changePosterBackground = (poster) => {
      if(currentPoster.value === poster) return;

      currentPoster.value = poster
      drawPoster()
    }

    // 切换缩略图显示状态
    const toggleThumbnails = () => {
      showThumbnails.value = !showThumbnails.value
    }

    // 点击海报区域
    const handlePosterClick = (e) => {
      // 获取点击位置相对于屏幕顶部的距离
      const clickY = e.clientY || e.touches[0].clientY
      // 如果点击位置在缩略图区域之外,则隐藏缩略图
      if (clickY > 180 && showThumbnails.value) {
        showThumbnails.value = false
      } else if (clickY <= 180) {
        // 点击缩略图区域,显示缩略图
        showThumbnails.value = true
      }
    }
</script>

<template>
    <view class="container" @click="handlePosterClick">
       <!-- 缩略图选择器 -->
       <view class="thumbnails-wrapper" :class="{ 'show': showThumbnails }">
        <view class="thumbnails-container">
          <view 
            v-for="poster in posterList" 
            :key="poster.id" 
            :class="['thumbnail-item', { 'active': currentPoster.id === poster.id }]"
            @click.stop="changePosterBackground(poster.src)"
          >
            <image :src="poster.src" mode="aspectFill" class="thumbnail-image" />
            <text class="thumbnail-name">{{ poster.name }}</text>
          </view>
        </view>
      </view>
  
      <!-- 提示信息 -->
      <view class="thumbnail-hint" @click.stop="toggleThumbnails">
        <text>{{ showThumbnails ? '点击选择背景' : '点击此处选择背景' }}</text>
      </view>
    </view>
</template>

3.总结

  • 背景图的比例是750 * 1300,
  • 背景图的二维码留空的左上角的起始位置: x: 447, y: 1221
  • 完整代码如下。
<script setup>
  import { reactive, ref, computed, onMounted } from 'vue';
  import { onLoad } from '@dcloudio/uni-app';
  import sheep from '@/sheep';
  import poster1 from '@/static/img/poster1.jpg'
  import poster2 from '@/static/img/poster2.jpg'
  import poster3 from '@/static/img/poster3.jpg'

  // 用户信息
  const userInfo = computed(() => sheep.$store('user').userInfo);

  const isPosterShow = ref(false)
  const canvasWidth = ref(0)
  const canvasHeight = ref(0)
  
  // 海报背景图列表
  const posterList = [
    { id: 1, src: poster1, name: '背景1' },
    { id: 2, src: poster2, name: '背景2' },
    { id: 3, src: poster3, name: '背景3' },
  ]

  // 当前选中的背景图
  const currentPoster = ref(posterList[0].src)
  // 是否显示缩略图选择器
  const showThumbnails = ref(false)



  // 海报尺寸比例
  const posterRatio = 2000 / 1524

  const posterSize = {
    width: 1524,
    height: 2000
  }

  const state = reactive({
    qrcodeSize: 630,
    // 二维码在海报中的位置比例
    qrcodePosition: {
      x: 447 / posterSize.width, // 水平位置比例
      y: 1221 / posterSize.height // 垂直位置比例
    }
  })

  const painterRef = ref(); // 海报画板
  const showPainter = ref(true)
  const painterImageUrl = ref(); // 海报 url
  const pixelRatio = ref(1)
  const scale = ref(1) // 缩放比例

  // 生成海报背景图
  const drawPoster = async () => {
    if(!painterImageUrl.value) {
      return uni.showToast({
        title: '二维码生成中,请稍后...',
        icon: 'none'
      })
    }

    await uni.showLoading({
      title: '海报生成中...',
      mask: true
    })


    try {
      // 创建canvas上下文
      const ctx = uni.createCanvasContext("mycanvas")
      // 清空画布
      ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
      // 绘制背景图
      ctx.drawImage(currentPoster.value, 0, 0, canvasWidth.value, canvasHeight.value)
      // 计算二维码位置和大小
      const qrcodeSize = state.qrcodeSize * scale.value
      const qrcodeX = canvasWidth.value * state.qrcodePosition.x
      const qrcodeY = canvasHeight.value * state.qrcodePosition.y
      // 绘制二维码
      ctx.drawImage(painterImageUrl.value, qrcodeX, qrcodeY, qrcodeSize, qrcodeSize)
      // 执行绘制
      ctx.draw(false, () => {
        setTimeout(() => {
          // 将canvas转为图片
          uni.canvasToTempFilePath({
            canvasId: 'mycanvas',
            success: (res) => {
              isPosterShow.value = true
              // 保存临时路径用于分享或保存
              state.tempFilePath = res.tempFilePath
              uni.hideLoading()
            },
            fail: (err) => {
              console.error('Canvas转图片失败:', err)
              uni.hideLoading()
            }
          })
        }, 300)
      })
    } catch(e) {
      console.error('绘制海报失败:', error)
      uni.hideLoading()
    }
  }


  // 渲染海报
  const renderPoster = async (poster) => {
    await painterRef.value.render(poster);
  };

  // 获得生成的图片
  const setPainterImageUrl = (path) => {
    painterImageUrl.value = path;
    showPainter.value = false
    // 二维码生成后立即绘制海报
    drawPoster()
  };

  /**
   * 生成核销二维码
   */
  const markCode = (text) => {
    renderPoster({
      css: {
        width: `${state.qrcodeSize}px`,
        height: `${state.qrcodeSize}px`
      },
      views:[
        {
          type: 'qrcode',
          text: text,
          css: {
            width: `${state.qrcodeSize}px`,
            height: `${state.qrcodeSize}px`
          }
        }
      ]
    })
  }

  // 复制邀请链接
  // #ifdef H5
  const onInviteRegister = () => {
    const rootUrl = getRootUrl();
    const realUrl = rootUrl.includes('localhost') ? 'http://192.168.2.19:3000/' : rootUrl;
    return realUrl + 'pages/auth/register?inviteCode=' + userInfo?.value.inviteCode
  }
  // #endif


  // 保存图片方法
  const saveImage = () => {
    // #ifdef H5
    uni.showLoading({
      title: '正在下载中...',
      mask: true
    })
    const xhr = new XMLHttpRequest();
    xhr.open('GET', state.tempFilePath, true);
    xhr.responseType = 'arraybuffer';    // 返回类型blob
    xhr.onload = function () {
      if (xhr.readyState === 4 && xhr.status === 200) {
        let blob = this.response;
        // 转换一个blob链接
        let u = window.URL.createObjectURL(new Blob([blob],{ type: 'image/png' })) // 视频的type是video/mp4,图片是image/jpeg
        let a = document.createElement('a');
        a.download = 'image'; // 设置下载的文件名
        a.href = u;
        a.style.display = 'none'
        document.body.appendChild(a)
        a.click();
        uni.hideLoading()
        document.body.removeChild(a);
      }
    };
    xhr.send()
    // #endif
  };

  // 计算画布尺寸
  const calculateCanvasSize = () => {
    const systemInfo = uni.getSystemInfoSync()
    const screenWidth = systemInfo.windowWidth
    // const screenHeight = systemInfo.windowHeight

    // 修改计算逻辑,保持原始比例
    // 使用固定比例,确保海报完整显示且不变形
    canvasWidth.value = screenWidth
    // canvasHeight.value = screenWidth * posterRatio
    canvasHeight.value = (screenWidth / 3) * 4 * posterRatio
    // 计算缩放比例
    scale.value = canvasWidth.value / posterSize.width

    // 设置像素比
    pixelRatio.value = systemInfo.pixelRatio || 1
  }


  // 切换海报背景
  const changePosterBackground = (poster) => {
    if(currentPoster.value === poster) return;

    currentPoster.value = poster
    drawPoster()
  }

  // 切换缩略图显示状态
  const toggleThumbnails = () => {
    showThumbnails.value = !showThumbnails.value
  }

  // 点击海报区域
  const handlePosterClick = (e) => {
    // 获取点击位置相对于屏幕顶部的距离
    const clickY = e.clientY || e.touches[0].clientY
    // 如果点击位置在缩略图区域之外,则隐藏缩略图
    if (clickY > 180 && showThumbnails.value) {
      showThumbnails.value = false
    } else if (clickY <= 180) {
      // 点击缩略图区域,显示缩略图
      showThumbnails.value = true
    }
  }

  onMounted(async () => {
    const url = onInviteRegister()
    markCode(url)
  })

  onLoad(() => {
    calculateCanvasSize()
  })
  
  /**
 * 获取H5-真实根地址 兼容hash+history模式
 */
export function getRootUrl() {
  let url = '';
  // #ifdef H5
  url = location.origin + '/';

  if (location.hash !== '') {
    url += '#/';
  }
  // #endif
  return url;
}
</script>

<template>
  <view class="container" @click="handlePosterClick">
     <!-- 缩略图选择器 -->
     <view class="thumbnails-wrapper" :class="{ 'show': showThumbnails }">
      <view class="thumbnails-container">
        <view 
          v-for="poster in posterList" 
          :key="poster.id" 
          :class="['thumbnail-item', { 'active': currentPoster.id === poster.id }]"
          @click.stop="changePosterBackground(poster.src)"
        >
          <image :src="poster.src" mode="aspectFill" class="thumbnail-image" />
          <text class="thumbnail-name">{{ poster.name }}</text>
        </view>
      </view>
    </view>
    
    <!-- 提示信息 -->
    <view class="thumbnail-hint" @click.stop="toggleThumbnails">
      <text>{{ showThumbnails ? '点击选择背景' : '点击此处选择背景' }}</text>
    </view>

    <view class="poster-wrapper">
      <canvas
        :disable-scroll="false"
        canvas-id="mycanvas"
        id="mycanvas"
        :style="{
          width: canvasWidth + 'px',
          height: canvasHeight + 'px'
        }"
      ></canvas>
    </view>

    <view class="button-wrapper">
      <button class="save-button" @click="saveImage">保存图片</button>
    </view>

    <!--  海报画板:默认隐藏只用来生成海报。生成方式为主动调用  -->
    <l-painter
      v-if="showPainter"
      isCanvasToTempFilePath
      pathType="url"
      @success="setPainterImageUrl"
      hidden
      ref="painterRef"
    />
  </view>
</template>

<style scoped lang="scss">
  .container {
    min-height: 100vh;
    width: 100vw;
    background-color: #000;
    position: relative;
    display: flex;
    flex-direction: column;
    overflow: auto;
  }

  .thumbnails-wrapper {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    background-color: rgba(0, 0, 0, 0.8);
    z-index: 100;
    padding: 20rpx;
    transform: translateY(-100%);
    transition: transform 0.3s ease;
    
    &.show {
      transform: translateY(0);
    }
  }
  
  .thumbnails-container {
    display: flex;
    justify-content: space-around;
    align-items: center;
    padding: 10rpx 0;
  }
  
  .thumbnail-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 150rpx;
    opacity: 0.7;
    transition: all 0.2s ease;
    
    &.active {
      opacity: 1;
      transform: scale(1.05);
    }
  }
  
  .thumbnail-image {
    width: 120rpx;
    height: 160rpx;
    border-radius: 8rpx;
    border: 2rpx solid transparent;
    
    .active & {
      border-color: rgb(195, 32, 33);
    }
  }
  
  .thumbnail-name {
    font-size: 24rpx;
    color: #fff;
    margin-top: 10rpx;
  }
  
  .thumbnail-hint {
    height: 60rpx;
    display: flex;
    justify-content: center;
    align-items: center;
    color: rgba(255, 255, 255, 0.7);
    font-size: 24rpx;
    background-color: rgba(0, 0, 0, 0.5);
    border-radius: 0 0 16rpx 16rpx;
  }

  .poster-wrapper {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: auto;
    overflow: visible;
    margin-top: 20rpx;
  }

  .button-wrapper {
    position: fixed;
    bottom: 32rpx;
    left: 150rpx;
    right: 150rpx;
    z-index: 10;
  }

  .save-button {
    background-color: rgb(195, 32, 33);
    color: #fff;
    border-radius: 40rpx;
    height: 88rpx;
    line-height: 88rpx;
    font-size: 32rpx;
    font-weight: bold;
    border: none;

    &:active {
      opacity: 0.8;
    }
  }
</style>

最后

  • 如有不正之处,请佬不吝赐教,万分感谢。
  • 祝大家向上!