痛点
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!: numbersuccess!: booleanmessage?: stringresult?: 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="10485760"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()interface UploadFileItem {status: 'uploading' | 'failed' | 'success'url: stringtype: string // 例如'image' | 'video'message: stringthumb?: stringisImage?: booleanisVideo?: boolean
}const props = withDefaults(defineProps<{modelValue: string | string[] | undefinedmaxCount: numbermaxSize: numbercompactMultiValue: booleanwidth?: numberheight?: numberaccept?: string}>(),{modelValue: undefined,maxCount: 1,maxSize: 10485760, // 10McompactMultiValue: 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(fileDomain.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(',') : fieldValemit(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 serverUrl: string = (await uploadFilePromise(file.url)) as stringconst updateRecord = data.fileList.find((item) => item.url === file.url)if (serverUrl && serverUrl.length > 0) {// 设置上传结果updateRecord.url = serverUrlupdateRecord.status = 'success'updateRecord.message = ''} else {updateRecord.status = 'failed'updateRecord.message = '上传失败'}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) => {const a = uni.uploadFile({url: import.meta.env.VITE_SERVER_BASEURL + '/api/tools/upload', // 前台图片上传地址filePath: dataurl,name: 'file',formData: {},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[]) => {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-regexvals.forEach((item) => {const extract = /((\w+\/)*)([^\0-\x1F\\/:*?"<>|]+\.([^.]+))$/.exec(item)if (extract) {const fileType = checkType(extract[4].toLocaleLowerCase)const newItem: UploadFileItem = {status: 'success',message: '',type: fileType,url: fileDomain + extract[1] + extract[3]}data.fileList.push(newItem)}})}
}
</script>
event.ts
export const UPDATE_MODEL_EVENT = 'update:modelValue'
export const CHANGE_EVENT = 'change'
export const INPUT_EVENT = 'input'