封装uview-plus上传组件up-upload,支持v-model绑定

痛点

vue上传组件拿到了一般无法直接使用,需要对其上下传的接口按照业务进行处理及定制。本次拿到的uview-plus也是一样,对其上传组件up-upload进行封装,令其更方便开发

目标

封装希望达到的目标,就是实现v-model的绑定。令其支持三种模式:

1)单文件绑定,双向绑定一个string,其值可以从数据库取对应的文件字段

2)多文档绑定,双向绑定一个string[],其值可以是一组一对多的数据,来自处理后的数据库数据或非结构化存储数据

3)split压缩绑定,双向绑定一个string,其值为逗号分隔的方式,存储到数据库对应的文件字段。可以根据文件数估算需要存储的字段大小

其它参数根据业务,只支持几个关键参数:

maxCount:决定单文件还是多文件,为1时为单文件,大于1时为多文件

maxSize:上传文件的大小,默认10M,最大设置和nginx最大文件包大小及commons-file-upload的配置有关。根据业务数据大小来前端限制

accept:支持的文件类型过滤

compactMultiValue:多文件时是否通过逗号压缩到一个string,例如:1/2024/0531/665981d8fbb9be8c8414c8da.png,1/2024/0531/665981d8fbb9be8c8414c8ea.png,temp/665a906afbb9c649baf1fbe8.jpg
默认为true,false的话则v-model需要绑定类型为Array<string>

其它的暂不考虑扩展

实现

下面是自己的代码的实现

说明:

1)默认为图片组件,也可以通过制定acept上传其它类型

2)import.meta.env.VITE_SERVER_BASEURL为服务器上传请求地址

3)fileDomain为pinia数据,在APP启动时,加载为服务器上传后的文件地址,例如oss地址,本地也可以例如:http://localhost:8080/upload

4) 数据请求Result格式定义:

export class Result<T> {
  // ccframe约定返回
  code!: number
  success!: boolean
  message?: string
  result?: T
}

5)上传请求 返回结果类型。为x-file-storage的返回dto,pont映射的defs.FileInfo类型如下

export class FileInfo {
    /** attr */
    attr?: ObjectMap<any, object>

    /** basePath */
    basePath?: string

    /** contentType */
    contentType?: string

    /** createTime */
    createTime?: string

    /** ext */
    ext?: string

    /** fileAcl */
    fileAcl?: object

    /** filename */
    filename?: string

    /** hashInfo */
    hashInfo?: ObjectMap<any, string>

    /** id */
    id?: string

    /** metadata */
    metadata?: ObjectMap<any, string>

    /** objectId */
    objectId?: string

    /** objectType */
    objectType?: string

    /** originalFilename */
    originalFilename?: string

    /** path */
    path?: string

    /** platform */
    platform?: string

    /** size */
    size?: number

    /** thContentType */
    thContentType?: string

    /** thFileAcl */
    thFileAcl?: object

    /** thFilename */
    thFilename?: string

    /** thMetadata */
    thMetadata?: ObjectMap<any, string>

    /** thSize */
    thSize?: number

    /** thUrl */
    thUrl?: string

    /** thUserMetadata */
    thUserMetadata?: ObjectMap<any, string>

    /** uploadId */
    uploadId?: string

    /** uploadStatus */
    uploadStatus?: number

    /** url */
    url?: string

    /** userMetadata */
    userMetadata?: ObjectMap<any, string>
  }

6) 解释下const extract = /((\w+\/)*)([^\0-\x1F\\/:*?"<>|]+\.([^.]+))$/.exec(item):

因为服务器存储的路径为:temp/<文件> 或<租户ID>/<年>/<月日>/<文件>。例如:
1/2024/0531/665981d8fbb9be8c8414c8da.png
1/2024/0531/665981d8fbb9be8c8414c8ea.png
temp/665a906afbb9c649baf1fbe8.jpg
因此有了这个正则提取文件各部分,这里主要是提取后缀名来进行类型的映射(up-upload组件需要)

组件实现类cc-upload.vue

<!--
  CcFrame的UView Plus Upload的上传组件
  1.支持单文件和多文件上传,支持单个值的多文件绑定(采用逗号分隔符)
  2.支持生成指定大小的缩略图(需要后端支持)缩放为contain模式
  @Author Jim 24/10/17
 -->
<template>
  <up-upload
    :fileList="data.fileList"
    @afterRead="afterRead"
    @delete="deletePic"
    :maxCount="props.maxCount"
    :maxSize="maxSize"
    :width="props.width"
    :height="props.height"
    :imageMode="props.imageMode"
    v-bind="$attrs"
  ></up-upload>
</template>
<script lang="ts" setup>
import { Result } from '@/utils/service'
import { useAppStore } from '@/store'
import { UPDATE_MODEL_EVENT, CHANGE_EVENT, INPUT_EVENT } from './event'
import * as utils from '@/utils'

// const { fileDomain, token } = useAppStore()
const { token } = useAppStore()
// const fileDomain = '/upload/'

// const filePerfix = fileDomain.startsWith('http')
//   ? fileDomain
//   : import.meta.env.VITE_SERVER_BASEURL + fileDomain

interface UploadFileItem {
  status: 'uploading' | 'failed' | 'success'
  url: string
  type: string // 例如'image' | 'video'
  message: string
  thumb?: string
  isImage?: boolean
  isVideo?: boolean
}

const props = withDefaults(
  defineProps<{
    modelValue: string | string[] | undefined // 绑定值,可以绑定单个值,也可以绑定多个值
    maxCount?: number // 最多的图片个数,当达到后,后面不会再出现+号添加新图片,只能更新已有图片
    maxSize?: number // 单个文件最大大小,默认10M
    compactMultiValue?: boolean // 是否采用逗号压缩多个文件,默认开启
    width: string | number // 单个内部预览图片区域和选择图片按钮的区域宽度,单位rpx,不能是百分比,或者auto
    height: string | number // 单个内部预览图片区域和选择图片按钮的区域高度,单位rpx,不能是百分比,或者auto
    thumbHeight?: number // 指定缩略图高度,不传则不生成缩略图(需要后端配合)
    thumbWidth?: number // 指定缩略图宽度,不传则不生成缩略图(需要后端配合)
    accept?: string
    imageMode: string // 图片裁剪模式,默认为aspectFill,即等比缩放,裁剪居中
  }>(),
  {
    modelValue: undefined,
    maxCount: 1,
    maxSize: 10485760, // 10M
    compactMultiValue: true,
    thumbHeight: undefined,
    thumbWidth: undefined,
    accept: '.gif,.jpg,.png,image/gif,image/jpeg,image/png', // 注意,默认是上传图片
    imageMode: 'aspectFill'
  }
)

const data = reactive<{
  fileList: Array<UploadFileItem>
}>({
  fileList: []
})

const emitVal = () => {
  const fieldVal: string[] = data.fileList
    .filter((item) => item.status === 'success')
    .map((item) => item.url.slice(utils.fileDomain.value.length)) // 只更新上传成功的
  if (props.maxCount === 1) {
    // 单数据
    const sigleVal = fieldVal.length === 0 ? undefined : fieldVal[0]
    emit(UPDATE_MODEL_EVENT, sigleVal)
    emit(CHANGE_EVENT, sigleVal)
    emit(INPUT_EVENT, sigleVal)
  } else {
    // 多数据
    const multiValue = props.compactMultiValue ? fieldVal.join(',') : fieldVal
    emit(UPDATE_MODEL_EVENT, multiValue)
    emit(CHANGE_EVENT, multiValue)
    emit(INPUT_EVENT, multiValue)
  }
}

const afterRead = async (event) => {
  const files = [].concat(event.file) // 当设置 mutiple 为 true 时, file 为数组格式,否则为对象格式,兼容两种
  // 添加到列表&上传中状态
  files.forEach((item) => {
    data.fileList.push({
      ...item, // 先占位
      status: 'uploading',
      message: '上传中'
    })
  })
  files.forEach(async (file) => {
    const fileUrl: string = (await uploadFilePromise(file.url)) as string
    const updateRecord = data.fileList.find((item) => item.url === file.url)
    console.log('*** update:', updateRecord, 'fileUrl:', fileUrl)
    if (fileUrl && fileUrl.length > 0) {
      // 有上传结果
      const serverUrl: string = fileUrl.startsWith('http')
        ? fileUrl
        : import.meta.env.VITE_SERVER_BASEURL + fileUrl
      console.log('....>>serverUrl=', serverUrl)
      // 设置上传结果
      updateRecord.url = serverUrl
      updateRecord.status = 'success'
      updateRecord.message = ''
    } else {
      updateRecord.status = 'failed'
      updateRecord.message = '上传失败'
    }
    console.log('....>>', JSON.stringify(data.fileList))
    emitVal()
  })
}

const deletePic = async (event) => {
  const fileData: UploadFileItem[] = data.fileList.splice(event.index, 1) // 直接删除本地,服务器上不管,由保存方法处理
  if (fileData[0].status === 'success') {
    emitVal()
  }
}

const uploadFilePromise = async (dataurl) => {
  return new Promise((resolve, reject) => {
    uni.uploadFile({
      // 这里异常时返回的数据是乱码,但是其他请求不会
      url:
        (process.env.VUE_APP_PLATFORM === 'h5' ? '' : import.meta.env.VITE_SERVER_BASEURL) +
        '/api/tools/upload', // 前台图片上传地址
      filePath: dataurl,
      name: 'file',
      header: {
        // 'content-type': 'multipart/form-data;charset=utf-8', // 去掉就正常,否则boundary编码错误
        apiToken: token
      },
      formData:
        props.thumbWidth && props.thumbHeight
          ? { thumbWidth: props.thumbWidth, thumbHeight: props.thumbHeight }
          : {},
      success: (res) => {
        if (res.statusCode === 200) {
          const uploadResult = JSON.parse(res.data) as Result<defs.FileInfo>
          if (uploadResult.code === 200) {
            // console.log('**************', uploadResult.result.url)
            resolve(uploadResult.result.url)
            return
          }
        }
        resolve('') // 上传失败
      },
      fail: (err) => {
        console.log(err)
        resolve('') // 上传失败
      }
    })
  })
}

const emit = defineEmits([UPDATE_MODEL_EVENT, CHANGE_EVENT, INPUT_EVENT])

watch(
  // modelValue重新赋值时,根据值解析path和filename、ext、url
  () => props.modelValue,
  (val) => {
    loadVal(val)
  }
)

const checkType: (string) => string = (fileExt) => {
  const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp']
  const videoExts = ['mp4', 'mkv', 'avi', 'mov', 'm4v']
  if (imageExts.indexOf(fileExt) > -1) {
    return 'image'
  }
  if (videoExts.indexOf(fileExt) > -1) {
    return 'video'
  }
  return 'file'
}

// methods
const loadVal = (val?: string | string[]) => {
  console.log('....reload value:', val)
  if (
    (Array.isArray(val) && props.maxCount === 1) ||
    (typeof val === 'string' && props.maxCount > 1 && props.compactMultiValue === false)
  ) {
    console.error('val type and maxCount mismatch!')
    return
  }
  data.fileList.splice(0, data.fileList.length)
  if (val) {
    const vals = []
    ;[].concat(val).forEach((item) => {
      vals.push(...item.split(','))
    })
    // eslint-disable-next-line no-control-regex
    vals.forEach((item: string) => {
      const pattern = item.startsWith('http')
        ? /((\/[a-zA-Z0-9_]+)*\/)([^\0-\x1F\\/:*?"<>|]+\.([^.]+))$/
        : /(([a-zA-Z0-9_]+\/)*)([^\0-\x1F\\/:*?"<>|]+\.([^.]+))$/
      const extract = pattern.exec(item)
      if (extract) {
        const fileType = checkType(extract[4].toLocaleLowerCase())
        const newItem: UploadFileItem = {
          status: 'success',
          message: '',
          type: fileType,
          url: utils.fileDomain.value + extract[1] + extract[3]
        }
        data.fileList.push(newItem)
      }
    })
  }
}
</script>

说明:

fileDomain为APP初始化后端返回的文件系统地址,对于文件系统和后端服务地址一致的情况,可以省略域名,直接返回基础路径:/upload/;对于文件系统和后端服务地址不一致的情况,则返回完成的路径,例如OSS地址等

event.ts

export const UPDATE_MODEL_EVENT = 'update:modelValue'
export const CHANGE_EVENT = 'change'
export const INPUT_EVENT = 'input'

-- [2024/07/05更新] --

修复上传文件解析正则

-- [2024/08/12更新] --
修复小程序上传后没有显示图片

-- [2024/10/17更新] --
增加了尺寸设置,这样可以增加预览的大小

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FoxMale007

文章非V全文可读,觉得好请打赏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值