You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

554 lines
14 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<view :class="['wd-upload', customClass]">
<!-- 预览列表 -->
<view :class="['wd-upload__preview', customPreviewClass]" v-for="(file, index) in uploadFiles" :key="index">
<!-- -->
<view class="wd-upload__status-content">
<image :src="file.url" mode="aspectFit" class="wd-upload__picture" @click="onPreviewImage(index)" />
</view>
<view v-if="file.status !== 'success'" class="wd-upload__mask wd-upload__status-content">
<!-- loading时展示loading图标和进度 -->
<view v-if="file.status === 'loading'" class="wd-upload__status-content">
<wd-loading :size="24" :color="loadingColor" />
<text class="wd-upload__progress-txt">{{ file.percent }}%</text>
</view>
<!-- 失败时展示失败图标以及失败信息 -->
<view v-if="file.status === 'fail'" class="wd-upload__status-content">
<wd-icon name="close-outline" custom-class="wd-upload__icon"></wd-icon>
<text class="wd-upload__progress-txt">{{ file.error || '上传失败' }}</text>
</view>
</view>
<!-- 上传状态为上传中时不展示移除按钮 -->
<wd-icon v-if="file.status !== 'loading' && !disabled" name="error-fill" custom-class="wd-upload__close" @click="removeFile(index)"></wd-icon>
</view>
<view @click="handleChoose">
<slot v-if="useDefaultSlot"></slot>
<!-- 唤起项 -->
<view
v-if="!useDefaultSlot && (!limit || uploadFiles.length < limit)"
:class="['wd-upload__evoke', disabled ? 'is-disabled' : '', customEvokeClass]"
>
<!-- 唤起项图标 -->
<wd-icon class="wd-upload__evoke-icon" name="fill-camera"></wd-icon>
<!-- 有限制个数时确认是否展示限制个数 -->
<view v-if="limit && showLimitNum" class="wd-upload__evoke-num">{{ uploadFiles.length }}/{{ limit }}</view>
</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'wd-upload',
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
}
}
</script>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { context, getType, isDef, isEqual } from '../common/util'
import { chooseFile } from './utils'
interface Props {
customClass?: string
customEvokeClass?: string
customPreviewClass?: string
// 多选
multiple?: boolean
// 接受类型 暂定接受类型为图片,视频后续添加
accept?: string
sizeType?: Array<string>
sourceType?: Array<string>
header?: Record<string, any>
name?: string
formData?: Record<string, any>
// 上传相关
action: string
fileList?: Record<string, any>[]
statusKey?: string
maxSize?: number
limit?: number
showLimitNum?: boolean
disabled?: boolean
useDefaultSlot?: boolean
// loading 相关
loadingType?: string
loadingColor?: string
loadingSize?: string
// eslint-disable-next-line @typescript-eslint/ban-types
beforePreview?: Function
// eslint-disable-next-line @typescript-eslint/ban-types
onPreviewFail?: Function
// eslint-disable-next-line @typescript-eslint/ban-types
beforeRemove?: Function
// eslint-disable-next-line @typescript-eslint/ban-types
beforeUpload?: Function
// 图片预览相关
// eslint-disable-next-line @typescript-eslint/ban-types
beforeChoose?: Function
// eslint-disable-next-line @typescript-eslint/ban-types
buildFormData?: Function
}
const props = withDefaults(defineProps<Props>(), {
customClass: '',
customEvokeClass: '',
customPreviewClass: '',
multiple: false,
fileList: () => [] as Record<string, any>[],
accept: 'image',
showLimitNum: true,
disabled: false,
sourceType: () => ['album', 'camera'],
sizeType: () => ['original', 'compressed'],
header: () => {
return {}
},
formData: () => {
return {}
},
name: 'file',
loadingType: 'ring',
loadingColor: '#ffffff',
loadingSize: '24px',
useDefaultSlot: false,
statusKey: 'status',
maxSize: Number.MAX_VALUE
})
const uploadFiles = ref<Record<string, any>[]>([])
watch(
() => props.fileList,
(val) => {
const { statusKey } = props
if (isEqual(val, uploadFiles.value)) return
const uploadFileList = val.map((item) => {
item.uid = context.id++
item[statusKey] = item[statusKey] || 'success'
item.size = item.size || ''
item.action = props.action || ''
item.response = item.response || ''
return item
})
uploadFiles.value = uploadFileList
},
{
deep: true,
immediate: true
}
)
watch(
() => props.limit,
(val) => {
if (val && val < uploadFiles.value.length) {
console.error('[wot-design]Error: props limit must less than fileList.length')
}
},
{
deep: true,
immediate: true
}
)
watch(
() => props.beforePreview,
(fn) => {
if (fn && getType(fn) !== 'function' && getType(fn) !== 'asyncfunction') {
console.error('The type of beforePreview must be Function')
}
},
{
deep: true,
immediate: true
}
)
watch(
() => props.onPreviewFail,
(fn) => {
if (fn && getType(fn) !== 'function' && getType(fn) !== 'asyncfunction') {
console.error('The type of onPreviewFail must be Function')
}
},
{
deep: true,
immediate: true
}
)
watch(
() => props.beforeRemove,
(fn) => {
if (fn && getType(fn) !== 'function' && getType(fn) !== 'asyncfunction') {
console.error('The type of beforeRemove must be Function')
}
},
{
deep: true,
immediate: true
}
)
watch(
() => props.beforeUpload,
(fn) => {
if (fn && getType(fn) !== 'function' && getType(fn) !== 'asyncfunction') {
console.error('The type of beforeUpload must be Function')
}
},
{
deep: true,
immediate: true
}
)
watch(
() => props.beforeChoose,
(fn) => {
if (fn && getType(fn) !== 'function' && getType(fn) !== 'asyncfunction') {
console.error('The type of beforeChoose must be Function')
}
},
{
deep: true,
immediate: true
}
)
watch(
() => props.buildFormData,
(fn) => {
if (fn && getType(fn) !== 'function' && getType(fn) !== 'asyncfunction') {
console.error('The type of buildFormData must be Function')
}
},
{
deep: true,
immediate: true
}
)
const emit = defineEmits(['fail', 'change', 'success', 'progress', 'oversize', 'chooseerror', 'remove'])
/**
* @description 初始化文件数据
* @param {Object} file 上传的文件
*/
function initFile(file) {
// 状态初始化
const initState = {
uid: context.id++,
// 仅h5支持 name
name: file.name || '',
status: 'loading',
size: file.size,
url: file.path,
action: props.action,
percent: 0
}
uploadFiles.value.push(initState)
const { buildFormData, formData = {} } = props
if (buildFormData) {
buildFormData({
file: initState,
formData,
resolve: (formData: Record<string, any>) => {
formData && handleUpload(initState, formData)
}
})
} else {
handleUpload(initState, formData)
}
}
/**
* @description 上传失败捕获
* @param {Object} err 错误返回信息
* @param {Object} file 上传的文件
*/
function handleError(err, file, formData: Record<string, any>) {
const { statusKey } = props
const index = uploadFiles.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
uploadFiles.value[index][statusKey] = 'fail'
uploadFiles.value[index].error = err.message
uploadFiles.value[index].response = err
emit('fail', { error: err, file, formData })
}
}
/**
* @description 上传成功捕获
* @param {Object} res 接口返回信息
* @param {Object} file 上传的文件
*/
function handleSuccess(res, file, formData: Record<string, any>) {
const { statusKey } = props
const index = uploadFiles.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
uploadFiles.value[index][statusKey] = 'success'
uploadFiles.value[index].response = res.data
emit('change', { fileList: uploadFiles.value })
emit('success', { file, fileList: uploadFiles.value, formData })
}
}
/**
* @description 上传中捕获
* @param {Object} res 接口返回信息
* @param {Object} file 上传的文件
*/
function handleProgress(res, file) {
const index = uploadFiles.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
uploadFiles.value[index].percent = res.progress
emit('progress', { response: res, file })
}
}
/**
* @description 上传操作
* @param {Object} file 上传的文件
*/
function handleUpload(file, formData: Record<string, any>) {
const { action, name, header = {}, accept } = props
const uploadTask = uni.uploadFile({
url: action,
header,
name,
fileName: name,
fileType: accept as 'image' | 'video' | 'audio',
formData,
filePath: file.url,
success(res) {
if (res.statusCode === 200) {
// 上传成功进行文件列表拼接
handleSuccess(res, file, formData)
} else {
// 上传失败处理
handleError(res, file, formData)
}
},
fail(err) {
// 上传失败处理
handleError(err, file, formData)
}
})
// 获取当前文件加载的百分比
uploadTask.onProgressUpdate((res) => {
/**
* res.progress: 上传进度
* res.totalBytesSent: 已经上传的数据长度
* res.totalBytesExpectedToSend: 预期需要上传的数据总长度
*/
handleProgress(res, file)
})
}
/**
* @description 选择文件的实际操作将chooseFile自己用promise包了一层
*/
function onChooseFile() {
const { multiple, maxSize, accept, sizeType, limit, sourceType, beforeUpload } = props
// 设置为只选择图片的时候使用 chooseImage 来实现
if (accept === 'image') {
// 文件选择
chooseFile({
multiple,
sizeType,
sourceType,
maxCount: limit ? limit - uploadFiles.value.length : 9
})
.then((res: any) => {
// 成功选择初始化file
let files: null | Array<any> = null
files = Array.prototype.slice.call(res.tempFiles)
// 单选只有一个
if (!multiple) {
files = files.slice(0, 1)
}
// 遍历列表逐个初始化上传参数
const mapFiles = (files) => {
files.forEach(async (file) => {
if (!isDef(file.size)) {
file.size = await getImageInfo(file.path)
}
file.size <= maxSize ? initFile(file) : emit('oversize', { file })
})
}
// 上传前的钩子
if (beforeUpload) {
// 向下兼容原来的参数写法2.2.0 向下兼容 2.1.0
if (beforeUpload.length === 2) {
beforeUpload(files, (isPass) => {
isPass && mapFiles(files)
})
} else {
beforeUpload({
files,
fileList: uploadFiles.value,
resolve: (isPass) => {
isPass && mapFiles(files)
}
})
}
} else {
mapFiles(files)
}
})
.catch((error) => {
emit('chooseerror', { error })
})
}
}
/**
* 获取图片信息
* @param src 图片地址
*/
function getImageInfo(src: string) {
return new Promise<number>((resolve, reject) => {
uni.getImageInfo({
src: src,
success: (res) => {
resolve(res.height * res.width)
},
fail: () => {
reject(0)
}
})
})
}
/**
* @description 选择文件,内置拦截选择操作
*/
function handleChoose() {
if (props.disabled) return
const { beforeChoose } = props
// 选择图片前的钩子
if (beforeChoose) {
// 向下兼容原来的参数写法2.2.0 向下兼容 2.1.0
if (beforeChoose.length === 2) {
beforeChoose(uploadFiles.value, (isPass) => {
isPass && onChooseFile()
})
} else {
beforeChoose({
fileList: uploadFiles.value,
resolve: (isPass) => {
isPass && onChooseFile()
}
})
}
} else {
onChooseFile()
}
}
/**
* @description 移除文件
* @param {Object} file 上传的文件
* @param {Number} index 删除
*/
function handleRemove(file, index?: number) {
uploadFiles.value.splice(
uploadFiles.value.findIndex((item) => item.uid === file.uid),
1
)
emit('change', {
fileList: uploadFiles.value
})
emit('remove', { file })
}
function removeFile(index) {
const { beforeRemove } = props
const intIndex = parseInt(index)
const file = uploadFiles.value[intIndex]
if (beforeRemove) {
// 向下兼容原来的参数写法2.2.0 向下兼容 2.1.0
if (beforeRemove.length === 3) {
beforeRemove(file, uploadFiles.value, (isPass) => {
isPass && handleRemove(file)
})
} else {
beforeRemove({
file,
index: intIndex,
fileList: uploadFiles.value,
resolve: (isPass) => {
isPass && handleRemove(file)
}
})
}
} else {
handleRemove(file)
}
}
function onPreview(index, lists) {
const { onPreviewFail } = props
uni.previewImage({
urls: lists,
current: lists[index],
fail() {
if (onPreviewFail) {
if (onPreviewFail.length === 2) {
onPreviewFail(index, lists)
} else {
onPreviewFail({
index,
imgList: lists
})
}
} else {
uni.showToast({ title: '预览图片失败', icon: 'none' })
}
}
})
}
function onPreviewImage(index) {
const { beforePreview } = props
const lists = uploadFiles.value.map((file) => file.url)
if (beforePreview) {
// 向下兼容原来的参数写法2.2.0 向下兼容 2.1.0
if (beforePreview.length === 2) {
beforePreview({ index, lists }, (isPass) => {
isPass && onPreview(index, lists)
})
} else {
beforePreview({
index,
imgList: lists,
resolve: (isPass) => {
isPass && onPreview(index, lists)
}
})
}
} else {
onPreview(index, lists)
}
}
</script>
<style lang="scss" scoped>
@import './index.scss';
</style>