痛点
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更新] --
增加了尺寸设置,这样可以增加预览的大小