封装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

<template>
  <up-upload
    :fileList="data.fileList"
    @afterRead="afterRead"
    @delete="deletePic"
    :maxCount="props.maxCount"
    :maxSize="maxSize"
    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'

const { fileDomain } = useAppStore()

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
    compactMultiValue?: boolean
    width?: number
    height?: number
    accept?: string
  }>(),
  {
    modelValue: undefined,
    maxCount: 1,
    maxSize: 10485760, // 10M
    compactMultiValue: true, // 默认开启多文件逗号压缩
    width: 80,
    height: 80,
    accept: '.gif,.jpg,.png,image/gif,image/jpeg,image/png' // 注意,默认是上传图片
  }
)

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

const emitVal = () => {
  const fieldVal: string[] = data.fileList
    .filter((item) => item.status === 'success')
    .map((item) => item.url.slice(filePerfix.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) => {
    console.log('*** *', 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)
    if (fileUrl && fileUrl.length > 0) {
      // 有上传结果
      const serverUrl: string = fileUrl.startsWith('http')
        ? fileUrl
        : import.meta.env.VITE_SERVER_BASEURL + fileUrl

      // 设置上传结果
      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',
      formData: {}, // TODO 指定处理缩略图thumbWidth和thumbHeight
      success: (res) => {
        if (res.statusCode === 200) {
          const uploadResult = JSON.parse(res.data) as Result<defs.FileInfo>
          if (uploadResult.code === 200) {
            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: filePerfix + 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更新] --
修复小程序上传后没有显示图片

在UView Plus框架中,如果你想要自定义某个引用组件的CSS样式,你可以按照以下步骤操作: 1. **引入CSS文件**:首先,在你的项目中创建或找到一个适合存放全局样式的地方(如`src/assets/css`),编写或导入你需要的CSS文件。 ```html <!-- 在index.html中引入 --> <link rel="stylesheet" href="path/to/your/custom-style.css"> ``` 2. **选择器绑定**:在`.custom-style.css`文件中,针对特定的UView Plus组件选择器添加样式。例如,如果你想修改一个名为`u-parse`的组件,可以这样写: ```css .u-parse { /* 这里是你需要的样式 */ color: red; font-size: 16px; } ``` 3. **属性选择器**:如果想基于组件的某些属性进行定制,可以使用属性选择器(`:attr()`),比如根据`class`属性: ```css .u-parse[class*='custom-class'] { background-color: yellow; } ``` 4. **覆盖内置样式**:如果你发现内置的样式不满足需求,可以在选择器中加入更高的优先级,如使用`!important`关键字,但请谨慎使用,因为它可能会对其他地方的样式造成影响。 5. **复用样式表**:为了保持代码整洁,你可以将相关的样式组织到单独的CSS模块中,然后通过`v-bind:class`或者`:style`指令动态应用。 ```html <u-parse :class="{'custom-style': isCustom}" :style="{color: customColor}"></u-parse> ``` 然后在你的Vue组件中管理类名和内联样式。 记得在引用组件的地方,确保已经正确安装了UView Plus并正确配置了样式路径。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值