vue3 + ts + uni-app 移动端封装图片上传添加水印

代码
javascript 复制代码
<template>
  <view class="camera-upload">
    <!-- 用于绘制水印的canvas(隐藏) -->
     <canvas
      v-if="canvasShow"
      canvas-id="watermarkCanvas"
      :width="canvasW"
      :height="canvasH"
      :style="{ width: `${canvasW}px`, height: `${canvasH}px` }"
      style="position: absolute; left: 0; top: 0; opacity: 0; pointer-events: none;"
    ></canvas>

    <!-- 已上传图片列表 -->
    <view class="image-list">
      <view 
        v-for="(image, index) in fileList" 
        :key="index" 
        class="image-item"
      >
        <image :src="image.url" mode="aspectFill"></image>
        <view class="delete-btn" @tap.stop="deleteImage(index)">
          <up-icon name="close" color="white" size="7"></up-icon>
        </view>
        <view class="preview-mask" @tap="previewImage(index)"></view>
      </view>
      <!-- 上传按钮 -->
      <view 
        v-if="fileList.length < maxCount && !disabledUpload" 
        class="upload-btn"
        @tap="chooseImage"
      >
        <text class="upload-icon">+</text>
        <text class="upload-text">拍照上传</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { defHttp } from '@/utils/http/defHttp'

interface FileItem {
  type?: string
  uid?: string
  name?: string
  status: 'success' | 'uploading'
  thumb?: string
  url: string
  message: string
  response: { message: string }
}

interface Props {
  maxCount?: number
  modelValue?: string | string[]
  disabledUpload?: boolean
  location?: Record<string, any>
  compressQuality?: number // 压缩质量,0-1
  maxWidth?: number // 最大宽度
  maxHeight?: number // 最大高度
}

const props = withDefaults(defineProps<Props>(), {
  maxCount: 5,
  modelValue: '',
  disabledUpload: false,
  location: undefined,
  compressQuality: 0.8,
  maxWidth: 1920,
  maxHeight: 1080
})

// 定义组件事件
const emit = defineEmits<{
  'update:modelValue': [val: string]
}>()

// 图片列表
const fileList = ref<FileItem[]>([])

const getFileName = (path: string): string =>
  path.replace(/\\/g, '/').split('/').pop() || ''

const initFileList = (paths: string | string[]) => {
  const pathStr = Array.isArray(paths) ? paths.join(',') : (paths || '')
  if (!pathStr) {
    fileList.value = []
    return
  }
  fileList.value = pathStr.split(',').map(p => {
    const url = defHttp.getFileAccessHttpUrl(p)
    return {
      type: 'image',
      uid: generateId(8),
      name: getFileName(p),
      status: 'success' as const,
      thumb: url,
      url,
      message: '',
      response: { message: p }
    }
  })
}

watch(() => props.modelValue, val => initFileList(val || ''), { immediate: true })

// 更新modelValue
const handlePathChange = () => {
  const arr = fileList.value
    .filter(f => f.status === 'success')
    .map(f => f.response.message)
  emit('update:modelValue', arr.join(','))
}

// 用于绘制水印的canvas是否显示
const canvasShow = ref(false)
let canvasW = ref(0); // 画布宽度
let canvasH = ref(0); // 画布高度

const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))

// 添加水印的函数
// 添加水印
const addWatermark = async (imagePath: string): Promise<string> => {
  try {
    const info = await getImageInfoWithRetry(imagePath)

    const sourceWidth = Number(info.width) || 0
    const sourceHeight = Number(info.height) || 0

    if (sourceWidth <= 0 || sourceHeight <= 0) {
      return imagePath
    }

    const { outputWidth, outputHeight } = calculateOutputSize(
      sourceWidth,
      sourceHeight,
      props.maxWidth,
      props.maxHeight
    )

    if (outputWidth <= 0 || outputHeight <= 0) {
      return imagePath
    }

    canvasShow.value = true
    canvasW.value = outputWidth
    canvasH.value = outputHeight

    const quality = Math.min(1, Math.max(0, props.compressQuality ?? 0.7))

    await nextTick()
    await sleep(32)

    const ctx = uni.createCanvasContext('watermarkCanvas', getCurrentInstance())

    ctx.clearRect(0, 0, outputWidth, outputHeight)

    const sourcePath =
      typeof info.path === 'string' && info.path.length > 0 ? info.path : imagePath
    if (!sourcePath) {
      return imagePath
    }
    ctx.drawImage(sourcePath, 0, 0, outputWidth, outputHeight)

    // ===== 水印配置 =====
    const currentTime = getCurrentTime()

    const fontSize = getAdaptiveFontSize(outputWidth)
    const lineHeight = fontSize * 1.3
    const startX = lineHeight
    let startY = outputHeight - 3 * lineHeight

    ctx.setFontSize(fontSize)
    ctx.setFillStyle('#ffffff')
    ctx.setGlobalAlpha(0.85)

    // 时间
    ctx.fillText(`时间: ${currentTime}`, startX, startY)

    // 经纬度
    if (props.location?.longitude && props.location?.latitude) {
      startY += lineHeight
      ctx.fillText(
        `经纬度: ${props.location.longitude}, ${props.location.latitude}`,
        startX,
        startY
      )
    }

    // 地址(自动换行)
    if (props.location?.address) {
      startY += lineHeight
      drawMultilineText(
        ctx,
        `位置: ${props.location.address}`,
        startX,
        startY,
        outputWidth - startX * 2,
        lineHeight
      )
    }

    // 执行绘制
    await drawAsync(ctx)

    const tempFile = await canvasToTempFileAsync(outputWidth, outputHeight, quality)

    return tempFile
  } catch (err) {
    return Promise.reject(err)
  } finally {
    canvasShow.value = false
  }
}

const getImageInfoAsync = (src: string) => {
  return new Promise<UniApp.GetImageInfoSuccessData>((resolve, reject) => {
    uni.getImageInfo({
      src,
      success: resolve,
      fail: reject
    })
  })
}

const getImageInfoWithRetry = async (src: string, retries = 2) => {
  let info = await getImageInfoAsync(src)
  let count = 0
  while (count < retries && (!info.width || !info.height)) {
    await sleep(50)
    info = await getImageInfoAsync(src)
    count += 1
  }
  return info
}

const drawAsync = (ctx: UniApp.CanvasContext) => {
  return new Promise<void>((resolve) => {
    ctx.draw(true, resolve)
  })
}

const canvasToTempFileAsync = (
  width: number,
  height: number,
  quality: number
) => {
  return new Promise<string>((resolve, reject) => {
    uni.canvasToTempFilePath({
      canvasId: 'watermarkCanvas',
      fileType: 'jpg',
      destWidth: width,
      destHeight: height,
      quality,
      success: res => resolve(res.tempFilePath),
      fail: reject
    })
  })
}

const calculateOutputSize = (
  width: number,
  height: number,
  maxWidth: number,
  maxHeight: number
) => {
  if (width <= maxWidth && height <= maxHeight) {
    return { outputWidth: width, outputHeight: height }
  }

  const ratio = Math.min(maxWidth / width, maxHeight / height)

  return {
    outputWidth: width * ratio,
    outputHeight: height * ratio
  }
}

const getAdaptiveFontSize = (canvasWidth: number) => {
  const baseWidth = 961
  const baseFontSize = 35
  return baseFontSize * (canvasWidth / baseWidth)
}

const drawMultilineText = (
  ctx: UniApp.CanvasContext,
  text: string,
  x: number,
  y: number,
  maxWidth: number,
  lineHeight: number
) => {
  let line = ''
  let currentY = y

  for (let i = 0; i < text.length; i++) {
    const testLine = line + text[i]
    const { width } = ctx.measureText(testLine)

    if (width > maxWidth && i > 0) {
      ctx.fillText(line, x, currentY)
      line = text[i]
      currentY += lineHeight
    } else {
      line = testLine
    }
  }

  ctx.fillText(line, x, currentY)
}

// 上传图片到服务器
const uploadImage = async (tempFilePath: string, name: string) => {
  // 创建上传中的文件对象
  const uploadFileItem: FileItem = {
    name,
    uid: generateId(8),
    type: 'image',
    status: 'uploading',
    thumb: tempFilePath,
    url: tempFilePath,
    message: '上传中',
    response: { message: '' }
  }
  
  // 添加到文件列表
  fileList.value.push(uploadFileItem)
  
  try {
    // 调用上传接口
    const res = await defHttp.uploadFileAction(tempFilePath)
    
    // 更新文件状态为成功
    const index = fileList.value.findIndex(item => item.uid === uploadFileItem.uid)
    if (index !== -1) {
      fileList.value[index] = {
        ...fileList.value[index],
        status: 'success',
        message: '',
        url: res,
        response: { message: res }
      }
    }
    
    // 更新modelValue
    handlePathChange()
  } catch (error) {
    // 更新文件状态为失败
    const index = fileList.value.findIndex(item => item.uid === uploadFileItem.uid)
    if (index !== -1) {
      fileList.value[index] = {
        ...fileList.value[index],
        status: 'success', // 即使失败也保留图片,实际项目中可能需要处理失败状态
        message: '上传失败',
        response: { message: '' }
      }
    }
    console.error('上传图片失败:', error)
  }
}

// 拍照选择图片
const chooseImage = async () => {
  // 计算剩余可上传数量
  const remainingCount = props.maxCount - fileList.value.length
  
  uni.chooseImage({
    count: remainingCount, // 可选择的图片数量
    sizeType: ['compressed'], // 原图或压缩图
    sourceType: ['camera'], // 仅从相机拍照
    success: async function (res) {
      console.log('uploadPicture', res)
      // 为每张图片添加水印并上传
      for (let index = 0; index < res.tempFilePaths.length; index++) {
        const imagePath = res.tempFilePaths[index];
        const name = (Array.isArray(res.tempFiles) && res.tempFiles[index] && 'name' in res.tempFiles[index] ? (res.tempFiles[index] as File).name : '');
        try {
          // // 添加水印
          const watermarkedImage = await addWatermark(imagePath)
          await uploadImage(watermarkedImage as string, name)
        } catch (error) {
          console.error('添加水印失败:', error)
          // 如果添加水印失败,使用原图上传
          await uploadImage(imagePath, name)
        }
      }
    }
  })
}

// 预览图片
const previewImage = (index: number) => {
  const urls = fileList.value.map(item => item.url)
  uni.previewImage({
    current: urls[index],
    urls
  })
}

// 删除图片
const deleteImage = (index: number) => {
  fileList.value.splice(index, 1)
  // 更新modelValue
  handlePathChange()
}

getCurrentTime = (): string => {
  const now = new Date()
  const year = now.getFullYear()
  const month = String(now.getMonth() + 1).padStart(2, '0')
  const day = String(now.getDate()).padStart(2, '0')
  const hours = String(now.getHours()).padStart(2, '0')
  const minutes = String(now.getMinutes()).padStart(2, '0')
  const seconds = String(now.getSeconds()).padStart(2, '0')
  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}

generateId(length = 8): string {
  const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let id = '';
  for (let i = 0; i < length; i++) {
    id += chars[Math.floor(Math.random() * chars.length)];
  }
  return id;
}
</script>

<style scoped>
.camera-upload {
  width: 100%;
}

.image-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 10px 0;
}

.image-item {
  width: 150rpx;
  aspect-ratio: 1;
  overflow: hidden;
  background-color: #f5f5f5;
  position: relative;
}

.image-item image {
  width: 100%;
  height: 100%;
}

.delete-btn {
  position: absolute;
  top: 0;
  right: 0;
  background-color: rgb(55, 55, 55);
  height: 14px;
  width: 14px;
  display: flex;
  flex-direction: row;
  border-bottom-left-radius: 100px;
  align-items: center;
  justify-content: center;
  z-index: 3;
}

.preview-mask {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
}

.upload-btn {
  width: 150rpx;
  aspect-ratio: 1;
  border: 1px dashed #d9d9d9;
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background-color: #fafafa;
}

.upload-icon {
  font-size: 24px;
  color: #999;
  margin-bottom: 4px;
}

.upload-text {
  font-size: 12px;
  color: #999;
}
</style>

说明:目前水印字体大小会根据图片宽高动态计算,可以修改getAdaptiveFontSize方法中的baseWidth、baseFontSize来调整初始大小。定位可以调用高德或百度接口通过经纬度获取具体地址。

相关推荐
霍理迪2 小时前
Vue—条件渲染与循环渲染
前端·javascript·vue.js
2501_915921432 小时前
常用iOS性能测试工具大全及使用指南
android·测试工具·ios·小程序·uni-app·cocoa·iphone
xixixin_2 小时前
【CSS】字体大小不一致?px与vw渲染差异的底层原理与解决方案
前端·css
小J听不清2 小时前
CSS 内边距(padding)全解析:取值规则 + 表格实战
前端·javascript·css·html·css3
zhangjikuan892 小时前
在 ArkTS 中,Promise 的使用比 TypeScript 更严格(必须显式指定泛型类型)
前端·javascript·typescript
桐溪漂流2 小时前
Uni-app H5 环境下 ResizeObserver 监听 mp-html 动态高度
前端·uni-app·html
Highcharts.js2 小时前
React 如何实现大数据量图表(性能优化指南)
前端·javascript·react.js·信息可视化·集成·highcharts
奔跑的呱呱牛2 小时前
如何设计一个可扩展的地图前端架构?从0到1的工程实践(OpenLayers)
前端·架构·openlayers