uni-file-picker vue3二次封装(本地上传 + v-model)

发布于:2025-07-25 ⋅ 阅读:(16) ⋅ 点赞:(0)

目录

组件核心结构

核心依赖说明

组件参数详解

1. Props 配置

2. Emits 事件

核心方法解析

1. onFileChange - 文件选择处理

2. 动态压缩质量算法

3. 文件列表管理

uni-file-picker 关键参数

样式设计要点

组件功能流程

总结


本文详细解析一个基于 uni-app 框架的图片上传组件实现,该组件集成了文件选择、格式验证、大小限制、图片压缩和上传功能。

组件核心结构

<template>
  <view class="image-uploader">
    <uni-file-picker
      multiple
      :limit="1"
      @select="onFileChange"
      :file-extname="fileType"
      @delete="removeImage"
      :modelValue="uploadedFiles"
      ref="FilePicker"
      :auto-upload="false"
      mode="grid"
    >
      <slot>
        <view class="upload-btn">
          <uni-icons type="camera-filled" color="#007aff" size="40"></uni-icons>
        </view>
      </slot>
    </uni-file-picker>
    <cmpay-compress ref="Compress"></cmpay-compress>
  </view>
</template>

<script setup>
let baseUrl = import.meta.env.VITE_BASE_URL
import { computed, ref } from 'vue'
import cmpayCompress from '../cmpay-compress/cmpay-compress.vue'

const props = defineProps({
  fileType: {
    type: Array,
    default: () => ['jpg', 'jpeg', 'png', 'gif']
  },
  // 最大
  maxSize: {
    type: Number,
    default: 10 // 10
  },
  // 最小
  minSize: {
    type: Number,
    default: 100 // 100kb
  },
  value: {
    type: [String, Object, Array],
    default: ''
  },
  //自己的上传地址
  action: {
    type: String,
    default: '/openness-api/h5/checkH5Photo'
  },
  // 上传文件字段名
  name: {
    type: String,
    default: 'filePath'
  },
  formData: {
    type: Object,
    default: () => {
      return {}
    }
  },
  headers: {
    type: Object,
    default: () => {
      return {}
    }
  }
})
const emit = defineEmits(['update:uploaded', 'update:value'])

const FilePicker = ref()
const Compress = ref()

const uploadedFiles = computed({
  get: () => {
    let val = props.value
    if (val) {
      let temp = 1
      // 首先将值转为数组
      const list = Array.isArray(val) ? val : val.split(',')
      // 然后将数组转为对象数组
      return list.map((item) => {
        if (typeof item === 'string') {
          item = {
            url: item
          }
        }
        // uid
        item.pic_md5 = item.pic_md5 || new Date().getTime() + temp++
        return item
      })
    } else {
      return []
    }
  },
  set: (val) => {
    let res = listToString(val)
    emit('update:value', res)
  }
})
const onFileChange = async (files) => {
  const file = files.tempFiles[0]

  // 检查文件格式
  if (!props.fileType.includes(file.name.split('.').pop().toLowerCase())) {
    uni.showModal({ title: '提示', content: '不支持的文件格式' })
    FilePicker.value.clearFiles(0)
    return
  }
  // 检查文件大小
  const maxBytes = props.maxSize * 1024 * 1024
  const minBytes = props.minSize * 1024
  if (file.size > maxBytes) {
    uni.showModal({
      title: '提示',
      content: `文件大小不能超过 ${props.maxSize} MB`
    })
    FilePicker.value.clearFiles(0)
    return
  }
  if (file.size < minBytes) {
    uni.showModal({
      title: '提示',
      content: `文件大小不能小于 ${props.minSize} KB`
    })
    FilePicker.value.clearFiles(0)
    return
  }

  const fileSize = file.size
  const quality = getQuality(fileSize)

  uni.showLoading({ title: '上传中...', mask: true })

  Compress.value
    .compress({
      src: file.path,
      quality: quality,
      progress: (res) => {
        console.log('压缩进度', res)
      }
    })
    .then(async (compressedPath) => {
      // 压缩成功,开始上传
      uni.uploadFile({
        url: baseUrl + props.action,
        filePath: compressedPath,
        name: props.name,
        headers: props.headers,
        formData: { ...props.formData },
        success: (uploadFileRes) => {
          const data = JSON.parse(uploadFileRes.data)
          emit('update:uploaded', { ...data.data, formData: props.formData }) // 上传结果回调
          if (data && data.result_code === 'success') {
            // 上传成功,更新文件列表
            uploadedFiles.value = [
              ...uploadedFiles.value,
              { url: data.data.picUrl, pic_md5: data.data.pic_md5 }
            ]
          } else {
            // 上传失败,删除对应文件
            FilePicker.value.clearFiles(0)
            uni.showModal({
              content: data.result_msg || '识别失败,请检查图片是否正确清晰',
              showCancel: false
            })
          }
        },
        fail: (error) => {
          uni.showModal({ title: '提示', content: '上传失败,网络异常' })
          console.error(error)
          FilePicker.value.clearFiles(0)
        },
        complete: () => {
          uni.hideLoading()
        }
      })
    })
    .catch((err) => {
      uni.hideLoading()
      uni.showModal({ title: '提示', content: '图片压缩失败: ' + err })
      FilePicker.value.clearFiles(0)
    })
}

// 计算压缩质量
const getQuality = (fileSize) => {
  const sizeMB = fileSize / (1024 * 1024)
  if (sizeMB > 6) return 0.4
  if (sizeMB > 4) return 0.6
  return 0.8
}

// 对象转成指定字符串分隔
const listToString = (list, separator) => {
  let strs = ''
  separator = separator || ','
  for (const i in list) {
    strs += list[i].url + separator
  }
  return strs != '' ? strs.substring(0, strs.length - 1) : ''
}

// 删除图片
const removeImage = (file) => {
  uploadedFiles.value = uploadedFiles.value.filter(
    (_, index) => index != file.index
  )
}
</script>

<style scoped>
.image-uploader {
  padding: 5px;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}

.header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 8px;
}

.upload-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 80px;
  height: 80px;
  border: 2px dashed #ddd;
  border-radius: 8px;
  color: #bbb;
}
</style>

核心依赖说明

  1. uni-file-picker - 文件选择器组件

  2. uni-icons - 图标组件

  3. 自定义压缩组件 (cmpay-compress)

<template>
  <view class="compress" v-if="canvasId">
    <canvas
      :canvas-id="canvasId"
      :style="{ width: canvasSize.width, height: canvasSize.height }"
    ></canvas>
  </view>
</template>

<script>
export default {
  data() {
    return {
      pic: '',
      canvasSize: {
        width: 0,
        height: 0
      },
      canvasId: ''
    }
  },
  mounted() {
    // 创建 canvasId
    if (!uni || !uni._helang_compress_canvas) {
      uni._helang_compress_canvas = 1
    } else {
      uni._helang_compress_canvas++
    }
    this.canvasId = `compress-canvas${uni._helang_compress_canvas}`
  },
  methods: {
    // 压缩
    compressFun(params) {
      return new Promise((resolve, reject) => {
        // 等待图片信息
        this.getImageInfo(params.src)
          .then((info) => {
            if (!info) {
              reject('获取图片信息异常')
              return
            }

            // 设置最大 & 最小 尺寸
            const maxSize = params.maxSize || 1080
            const minSize = params.minSize || 640

            // 当前图片尺寸
            let { width, height } = info

            // 非 H5 平台进行最小尺寸校验
            // #ifndef H5
            if (width <= minSize && height <= minSize) {
              resolve(params.src)
              return
            }
            // #endif

            // 最大尺寸计算
            //(图像的宽度和高度是否超过最大尺寸。如果其中任一维度超过最大尺寸,代码将对图像进行调整,以使其适应最大尺寸并保持其宽高比。)
            // 这样可以确保图像在调整大小后仍保持原始比例,并且不会超过指定的最大尺寸

            if (width > maxSize || height > maxSize) {
              if (width > height) {
                height = Math.floor(height / (width / maxSize))
                width = maxSize
              } else {
                width = Math.floor(width / (height / maxSize))
                height = maxSize
              }
            }

            // 设置画布尺寸
            this.$set(this, 'canvasSize', {
              width: `${width}px`,
              height: `${height}px`
            })

            // Vue.nextTick 回调在 App 有异常,则使用 setTimeout 等待DOM更新
            setTimeout(() => {
              // 创建 canvas 绘图上下文(指定 canvasId)。在自定义组件下,第二个参数传入组件实例this,以操作组件内 <canvas/> 组件
              // Tip: 需要指定 canvasId,该绘图上下文只作用于对应的 <canvas/>
              const ctx = uni.createCanvasContext(this.canvasId, this)
              // 清除画布上在该矩形区域内的内容。(x,y,宽,高)
              ctx.clearRect(0, 0, width, height)
              // 绘制图像到画布。(所要绘制的图片资源,x,y,宽,高)
              ctx.drawImage(info.path, 0, 0, width, height)
              // 将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中。
              // 本次绘制是否接着上一次绘制,即reserve参数为false,则在本次调用drawCanvas绘制之前native层应先清空画布再继续绘制;若reserver参数为true,则保留当前画布上的内容,本次调用drawCanvas绘制的内容覆盖在上面,默认 false
              // 绘制完成后回调
              ctx.draw(false, () => {
                // 把当前画布指定区域的内容导出生成指定大小的图片,并返回文件路径。在自定义组件下,第二个参数传入自定义组件实例,以操作组件内 <canvas> 组件。
                uni.canvasToTempFilePath(
                  {
                    x: 0, //画布x轴起点(默认0)
                    y: 0, //画布y轴起点(默认0)
                    width: width, //画布宽度(默认为canvas宽度-x)
                    height: height, //画布高度(默认为canvas高度-y
                    destWidth: width, //图片宽度(默认为 width * 屏幕像素密度)
                    destHeight: height, //输出图片高度(默认为 height * 屏幕像素密度)
                    canvasId: this.canvasId, //画布标识,传入 <canvas/> 的 canvas-id(支付宝小程序是id、其他平台是canvas-id)
                    fileType: params.fileType || 'png', //目标文件的类型,只支持 'jpg' 或 'png'。默认为 'png'
                    quality: params.quality || 0.9, //图片的质量,取值范围为 (0, 1],不在范围内时当作1.0处理
                    success: (res) => {
                      // 在H5平台下,tempFilePath 为 base64
                      resolve(res.tempFilePath)
                    },
                    fail: (err) => {
                      console.log(err)

                      reject(null)
                    }
                  },
                  this
                )
              })
            }, 300)
          })
          .catch((err) => {
            console.log(err)
            reject('获取图片信息异常')
          })
      })
    },
    // 获取图片信息
    getImageInfo(src) {
      return new Promise((resolve, reject) => {
        uni.getImageInfo({
          src,
          success: (info) => {
            resolve(info)
          },
          fail: (err) => {
            console.log(err, 'err===获取图片信息')
            reject(null)
          }
        })
      })
    },
    // 批量压缩
    async compress(params) {
      // 初始化状态变量
      let [index, done, fail] = [0, 0, 0]
      let paths = []

      // 处理待压缩图片列表
      let waitList = Array.isArray(params.src) ? params.src : [params.src]

      // 批量压缩方法
      let batch = async () => {
        while (index < waitList.length) {
          try {
            const path = await next()
            done++
            paths.push(path)
            params.progress?.({ done, fail, count: waitList.length })
          } catch (error) {
            fail++
            params.progress?.({ done, fail, count: waitList.length })
          }
          index++
        }
      }

      // 单个图片压缩方法
      let next = () => {
        const currentSrc = waitList[index]
        return this.compressFun({
          src: currentSrc,
          maxSize: params.maxSize,
          fileType: params.fileType,
          quality: params.quality,
          minSize: params.minSize
        })
      }
      // 返回Promise并处理结果
      return new Promise((resolve, reject) => {
        try {
          batch()
            .then(() => {
              if (typeof params.src === 'string') {
                resolve(paths[0])
              } else {
                resolve(paths)
              }
            })
            .catch((error) => {
              reject(error)
            })
        } catch (error) {
          reject(error)
        }
      })
    }
  }
}
</script>

<style lang="scss" scoped>
.compress {
  position: fixed;
  width: 12px;
  height: 12px;
  overflow: hidden;
  top: -99999px;
  left: 0;
}
</style>

组件参数详解

1. Props 配置
参数名 类型 默认值 说明
fileType Array ['jpg','jpeg','png','gif'] 允许上传的文件类型
maxSize Number 10 (MB) 文件大小上限
minSize Number 100 (KB) 文件大小下限
value [String,Object,Array] '' 已上传文件数据(支持字符串/对象/数组格式)
action String /openness-api/h5/checkH5Photo 上传接口地址
name String 'filePath' 上传文件的字段名
formData Object {} 上传时附加的表单数据
headers Object {} 上传请求头配置
2. Emits 事件
事件名 说明
update:uploaded 上传完成时触发,返回服务器响应数据
update:value 文件列表变更时触发,更新绑定值

核心方法解析

1. onFileChange - 文件选择处理
const onFileChange = async (files) => {
  // 1. 获取文件并验证格式
  const file = files.tempFiles[0];
  const ext = file.name.split('.').pop().toLowerCase();
  
  if (!props.fileType.includes(ext)) {
    uni.showModal({ title: '提示', content: '不支持的文件格式' });
    return;
  }

  // 2. 验证文件大小
  const maxBytes = props.maxSize * 1024 * 1024;
  const minBytes = props.minSize * 1024;
  
  if (file.size > maxBytes) {
    uni.showModal({ title: '提示', content: `文件大小不能超过 ${props.maxSize} MB` });
    return;
  }
  
  if (file.size < minBytes) {
    uni.showModal({ title: '提示', content: `文件大小不能小于 ${props.minSize} KB` });
    return;
  }

  // 3. 计算压缩质量
  const quality = getQuality(file.size);
  
  // 4. 执行压缩
  uni.showLoading({ title: '上传中...', mask: true });
  
  try {
    const compressedPath = await Compress.value.compress({
      src: file.path,
      quality: quality
    });

    // 5. 上传文件
    uni.uploadFile({
      url: baseUrl + props.action,
      filePath: compressedPath,
      name: props.name,
      headers: props.headers,
      formData: { ...props.formData },
      success: (res) => {
        const data = JSON.parse(res.data);
        // 处理上传结果...
      },
      fail: (error) => {
        // 错误处理...
      },
      complete: () => uni.hideLoading()
    });
  } catch (err) {
    // 压缩错误处理...
  }
}
2. 动态压缩质量算法
const getQuality = (fileSize) => {
  const sizeMB = fileSize / (1024 * 1024);
  if (sizeMB > 6) return 0.4;   // >6MB 使用40%质量
  if (sizeMB > 4) return 0.6;   // >4MB 使用60%质量
  return 0.8;                   // 其他使用80%质量
}
3. 文件列表管理
// 计算属性:转换value为可用格式
const uploadedFiles = computed({
  get: () => {
    let val = props.value;
    if (!val) return [];
    
    const list = Array.isArray(val) ? val : val.split(',');
    return list.map((item, index) => ({
      url: typeof item === 'string' ? item : item.url,
      pic_md5: item.pic_md5 || Date.now() + index
    }));
  },
  set: (val) => {
    const urls = val.map(item => item.url);
    emit('update:value', urls.join(','));
  }
});

// 删除文件处理
const removeImage = (file) => {
  uploadedFiles.value = uploadedFiles.value.filter(
    (_, index) => index !== file.index
  );
}

uni-file-picker 关键参数

参数 说明
multiple 是否支持多选(实际被limit=1限制为单选)
:limit="1" 最大选择文件数量
@select 文件选择事件
@delete 文件删除事件
:file-extname 允许的文件扩展名
:modelValue 绑定的文件列表
:auto-upload="false" 关闭自动上传(手动控制上传流程)
mode="grid" 网格显示模式
ref="FilePicker" 组件引用,用于调用clearFiles等方法

样式设计要点

.image-uploader {
  padding: 5px;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); /* 添加微妙阴影 */
}

.upload-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 80px;
  height: 80px;
  border: 2px dashed #ddd; /* 虚线边框 */
  border-radius: 8px;
  color: #bbb;
  transition: all 0.3s; /* 添加过渡效果 */
}

.upload-btn:active {
  background-color: #f9f9f9; /* 点击反馈 */
}

组件功能流程

  1. 用户交互:点击相机图标触发文件选择

  2. 文件验证

    • 格式验证(jpg/jpeg/png/gif)

    • 大小验证(100KB-10MB)

  3. 图片处理

    • 根据文件大小动态计算压缩质量

    • 调用压缩组件进行图片压缩

  4. 文件上传

    • 显示加载状态

    • 携带自定义表单数据和请求头

    • 处理上传结果

  5. 状态管理

    • 成功:更新文件列表

    • 失败:显示错误信息

  6. 文件删除

    • 从文件列表中移除项目

    • 更新绑定数据

总结

该图片上传组件提供了完整的文件处理解决方案:

  1. 通过3实现文件选择

  2. 严格的格式和大小验证

  3. 智能的图片压缩策略

  4. 灵活的上传配置(地址/字段/请求头)

  5. 完善的状态管理和错误处理

组件设计考虑了移动端用户体验,包括:

  • 友好的提示信息

  • 加载状态反馈

  • 直观的操作流程

  • 美观的视觉设计

开发者可根据实际需求调整验证规则、压缩算法和UI样式,以适应不同项目需求。


网站公告

今日签到

点亮在社区的每一天
去签到