痛点
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="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: 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(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(',') : 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 serverUrl: string = (await uploadFilePromise(file.url)) as string
const updateRecord = data.fileList.find((item) => item.url === file.url)
if (serverUrl && serverUrl.length > 0) {
// 设置上传结果
updateRecord.url = serverUrl
updateRecord.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-regex
vals.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'