674 lines
18 KiB
Vue
674 lines
18 KiB
Vue
<template>
|
||
<view :class="['wd-upload', customClass]" :style="customStyle">
|
||
<!-- 预览列表 -->
|
||
<view :class="['wd-upload__preview', customPreviewClass]" v-for="(file, index) in uploadFiles" :key="index">
|
||
<!-- 成功时展示图片 -->
|
||
<view class="wd-upload__status-content">
|
||
<image v-if="isImage(file)" :src="file.url" :mode="imageMode" class="wd-upload__picture" @click="onPreviewImage(file)" />
|
||
<template v-else-if="isVideo(file)">
|
||
<view class="wd-upload__video" v-if="file.thumb" @click="onPreviewVideo(file)">
|
||
<image :src="file.thumb" :mode="imageMode" class="wd-upload__picture" />
|
||
<wd-icon name="play-circle-filled" custom-class="wd-upload__video-paly"></wd-icon>
|
||
</view>
|
||
<view v-else class="wd-upload__video" @click="onPreviewVideo(file)">
|
||
<!-- #ifdef APP-PLUS || MP-DINGTALK -->
|
||
<wd-icon custom-class="wd-upload__video-icon" name="video"></wd-icon>
|
||
<!-- #endif -->
|
||
<!-- #ifndef APP-PLUS -->
|
||
<!-- #ifndef MP-DINGTALK -->
|
||
<video
|
||
:src="file.url"
|
||
:title="file.name || '视频' + index"
|
||
object-fit="contain"
|
||
:controls="false"
|
||
:poster="file.thumb"
|
||
:autoplay="false"
|
||
:show-center-play-btn="false"
|
||
:show-fullscreen-btn="false"
|
||
:show-play-btn="false"
|
||
:show-loading="false"
|
||
:show-progress="false"
|
||
:show-mute-btn="false"
|
||
:enable-progress-gesture="false"
|
||
:enableNative="true"
|
||
class="wd-upload__video"
|
||
></video>
|
||
<wd-icon name="play-circle-filled" custom-class="wd-upload__video-paly"></wd-icon>
|
||
<!-- #endif -->
|
||
<!-- #endif -->
|
||
</view>
|
||
</template>
|
||
|
||
<view v-else class="wd-upload__file" @click="onPreviewFile(file)">
|
||
<wd-icon name="file" custom-class="wd-upload__file-icon"></wd-icon>
|
||
<view class="wd-upload__file-name">{{ file.name || file.url }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="file[props.statusKey] !== 'success'" class="wd-upload__mask wd-upload__status-content">
|
||
<!-- loading时展示loading图标和进度 -->
|
||
<view v-if="file[props.statusKey] === 'loading'" class="wd-upload__status-content">
|
||
<wd-loading :type="loadingType" :size="loadingSize" :color="loadingColor" />
|
||
<text class="wd-upload__progress-txt">{{ file.percent }}%</text>
|
||
</view>
|
||
<!-- 失败时展示失败图标以及失败信息 -->
|
||
<view v-if="file[props.statusKey] === '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 || translate('error') }}</text>
|
||
</view>
|
||
</view>
|
||
<!-- 上传状态为上传中时不展示移除按钮 -->
|
||
<wd-icon
|
||
v-if="file[props.statusKey] !== 'loading' && !disabled"
|
||
name="error-fill"
|
||
custom-class="wd-upload__close"
|
||
@click="removeFile(index)"
|
||
></wd-icon>
|
||
<!-- 自定义预览样式 -->
|
||
<slot name="preview-cover" v-if="$slots['preview-cover']" :file="file" :index="index"></slot>
|
||
</view>
|
||
|
||
<block v-if="showUpload">
|
||
<view :class="['wd-upload__evoke-slot', customEvokeClass]" v-if="$slots.default" @click="onEvokeClick">
|
||
<slot></slot>
|
||
</view>
|
||
<!-- 唤起项 -->
|
||
<view v-else @click="onEvokeClick" :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>
|
||
</block>
|
||
</view>
|
||
<wd-video-preview ref="videoPreview"></wd-video-preview>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
export default {
|
||
name: 'wd-upload',
|
||
options: {
|
||
addGlobalClass: true,
|
||
virtualHost: true,
|
||
styleIsolation: 'shared'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<script lang="ts" setup>
|
||
import wdIcon from '../wd-icon/wd-icon.vue'
|
||
import wdVideoPreview from '../wd-video-preview/wd-video-preview.vue'
|
||
import wdLoading from '../wd-loading/wd-loading.vue'
|
||
|
||
import { computed, ref, watch } from 'vue'
|
||
import { context, isEqual, isImageUrl, isVideoUrl, isFunction, isDef, deepClone } from '../common/util'
|
||
import { useTranslate } from '../composables/useTranslate'
|
||
import { useUpload } from '../composables/useUpload'
|
||
import {
|
||
uploadProps,
|
||
type UploadFileItem,
|
||
type ChooseFile,
|
||
type UploadExpose,
|
||
type UploadErrorEvent,
|
||
type UploadChangeEvent,
|
||
type UploadSuccessEvent,
|
||
type UploadProgressEvent,
|
||
type UploadOversizeEvent,
|
||
type UploadRemoveEvent,
|
||
type UploadMethod
|
||
} from './types'
|
||
import type { VideoPreviewInstance } from '../wd-video-preview/types'
|
||
|
||
const props = defineProps(uploadProps)
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'fail', value: UploadErrorEvent): void
|
||
(e: 'change', value: UploadChangeEvent): void
|
||
(e: 'success', value: UploadSuccessEvent): void
|
||
(e: 'progress', value: UploadProgressEvent): void
|
||
(e: 'oversize', value: UploadOversizeEvent): void
|
||
(e: 'chooseerror', value: any): void
|
||
(e: 'remove', value: UploadRemoveEvent): void
|
||
(e: 'update:fileList', value: UploadFileItem[]): void
|
||
}>()
|
||
|
||
defineExpose<UploadExpose>({
|
||
submit: () => startUploadFiles(),
|
||
abort: () => abort()
|
||
})
|
||
|
||
const { translate } = useTranslate('upload')
|
||
|
||
const uploadFiles = ref<UploadFileItem[]>([])
|
||
|
||
const showUpload = computed(() => !props.limit || uploadFiles.value.length < props.limit)
|
||
|
||
const videoPreview = ref<VideoPreviewInstance>()
|
||
|
||
const { startUpload, abort, chooseFile, UPLOAD_STATUS } = useUpload()
|
||
|
||
watch(
|
||
() => props.fileList,
|
||
(val) => {
|
||
const { statusKey } = props
|
||
if (isEqual(val, uploadFiles.value)) return
|
||
const uploadFileList: UploadFileItem[] = val.map((item) => {
|
||
item[statusKey] = item[statusKey] || 'success'
|
||
item.response = item.response || ''
|
||
return { ...item, uid: context.id++ }
|
||
})
|
||
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 && !isFunction(fn)) {
|
||
console.error('The type of beforePreview must be Function')
|
||
}
|
||
},
|
||
{
|
||
deep: true,
|
||
immediate: true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.onPreviewFail,
|
||
(fn) => {
|
||
if (fn && !isFunction(fn)) {
|
||
console.error('The type of onPreviewFail must be Function')
|
||
}
|
||
},
|
||
{
|
||
deep: true,
|
||
immediate: true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.beforeRemove,
|
||
(fn) => {
|
||
if (fn && !isFunction(fn)) {
|
||
console.error('The type of beforeRemove must be Function')
|
||
}
|
||
},
|
||
{
|
||
deep: true,
|
||
immediate: true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.beforeUpload,
|
||
(fn) => {
|
||
if (fn && !isFunction(fn)) {
|
||
console.error('The type of beforeUpload must be Function')
|
||
}
|
||
},
|
||
{
|
||
deep: true,
|
||
immediate: true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.beforeChoose,
|
||
(fn) => {
|
||
if (fn && !isFunction(fn)) {
|
||
console.error('The type of beforeChoose must be Function')
|
||
}
|
||
},
|
||
{
|
||
deep: true,
|
||
immediate: true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.buildFormData,
|
||
(fn) => {
|
||
if (fn && !isFunction(fn)) {
|
||
console.error('The type of buildFormData must be Function')
|
||
}
|
||
},
|
||
{
|
||
deep: true,
|
||
immediate: true
|
||
}
|
||
)
|
||
|
||
function emitFileList() {
|
||
emit('update:fileList', uploadFiles.value)
|
||
}
|
||
|
||
/**
|
||
* 开始上传文件
|
||
*/
|
||
function startUploadFiles() {
|
||
const { buildFormData, formData = {}, statusKey } = props
|
||
const { action, name, header = {}, accept, successStatus, uploadMethod } = props
|
||
const statusCode = isDef(successStatus) ? successStatus : 200
|
||
|
||
for (const uploadFile of uploadFiles.value) {
|
||
// 仅开始未上传的文件
|
||
if (uploadFile[statusKey] === UPLOAD_STATUS.PENDING) {
|
||
if (buildFormData) {
|
||
buildFormData({
|
||
file: uploadFile,
|
||
formData,
|
||
resolve: (formData: Record<string, any>) => {
|
||
formData &&
|
||
startUpload(uploadFile, {
|
||
action,
|
||
header,
|
||
name,
|
||
formData,
|
||
fileType: accept as 'image' | 'video' | 'audio',
|
||
statusCode,
|
||
statusKey,
|
||
uploadMethod,
|
||
onSuccess: handleSuccess,
|
||
onError: handleError,
|
||
onProgress: handleProgress
|
||
})
|
||
}
|
||
})
|
||
} else {
|
||
startUpload(uploadFile, {
|
||
action,
|
||
header,
|
||
name,
|
||
formData,
|
||
fileType: accept as 'image' | 'video' | 'audio',
|
||
statusCode,
|
||
statusKey,
|
||
uploadMethod,
|
||
onSuccess: handleSuccess,
|
||
onError: handleError,
|
||
onProgress: handleProgress
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取图片信息
|
||
* @param img
|
||
*/
|
||
function getImageInfo(img: string) {
|
||
return new Promise<UniApp.GetImageInfoSuccessData>((resolve, reject) => {
|
||
uni.getImageInfo({
|
||
src: img,
|
||
success: (res) => {
|
||
resolve(res)
|
||
},
|
||
fail: (error) => {
|
||
reject(error)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* @description 初始化文件数据
|
||
* @param {Object} file 上传的文件
|
||
*/
|
||
function initFile(file: ChooseFile, currentIndex?: number) {
|
||
const { statusKey } = props
|
||
// 状态初始化
|
||
const initState: UploadFileItem = {
|
||
uid: context.id++,
|
||
// 仅h5支持 name
|
||
name: file.name || '',
|
||
thumb: file.thumb || '',
|
||
[statusKey]: 'pending',
|
||
size: file.size || 0,
|
||
url: file.path,
|
||
percent: 0
|
||
}
|
||
if (typeof currentIndex === 'number') {
|
||
uploadFiles.value.splice(currentIndex, 1, initState)
|
||
} else {
|
||
uploadFiles.value.push(initState)
|
||
}
|
||
if (props.autoUpload) {
|
||
startUploadFiles()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @description 上传失败捕获
|
||
* @param {Object} err 错误返回信息
|
||
* @param {Object} file 上传的文件
|
||
*/
|
||
function handleError(err: Record<string, any>, file: UploadFileItem, 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 })
|
||
emitFileList()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @description 上传成功捕获
|
||
* @param {Object} res 接口返回信息
|
||
* @param {Object} file 上传的文件
|
||
*/
|
||
function handleSuccess(res: Record<string, any>, file: UploadFileItem, 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 })
|
||
emitFileList()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @description 上传中捕获
|
||
* @param {Object} res 接口返回信息
|
||
* @param {Object} file 上传的文件
|
||
*/
|
||
function handleProgress(res: UniApp.OnProgressUpdateResult, file: UploadFileItem) {
|
||
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 选择文件的实际操作,将chooseFile自己用promise包了一层
|
||
*/
|
||
function onChooseFile(currentIndex?: number) {
|
||
const { multiple, maxSize, accept, sizeType, limit, sourceType, compressed, maxDuration, camera, beforeUpload, extension } = props
|
||
chooseFile({
|
||
multiple: isDef(currentIndex) ? false : multiple,
|
||
sizeType,
|
||
sourceType,
|
||
maxCount: limit ? limit - uploadFiles.value.length : limit,
|
||
accept,
|
||
compressed,
|
||
maxDuration,
|
||
camera,
|
||
extension
|
||
})
|
||
.then((res) => {
|
||
// 成功选择初始化file
|
||
let files = res
|
||
// 单选只有一个
|
||
if (!multiple) {
|
||
files = files.slice(0, 1)
|
||
}
|
||
// 遍历列表逐个初始化上传参数
|
||
const mapFiles = async (files: ChooseFile[]) => {
|
||
for (let index = 0; index < files.length; index++) {
|
||
const file = files[index]
|
||
if (file.type === 'image' && !file.size) {
|
||
const imageInfo = await getImageInfo(file.path)
|
||
file.size = imageInfo.width * imageInfo.height
|
||
}
|
||
Number(file.size) <= maxSize ? initFile(file, currentIndex) : emit('oversize', { file })
|
||
}
|
||
}
|
||
|
||
// 上传前的钩子
|
||
if (beforeUpload) {
|
||
beforeUpload({
|
||
files,
|
||
fileList: uploadFiles.value,
|
||
resolve: (isPass: boolean) => {
|
||
isPass && mapFiles(files)
|
||
}
|
||
})
|
||
} else {
|
||
mapFiles(files)
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
emit('chooseerror', { error })
|
||
})
|
||
}
|
||
|
||
/**
|
||
* @description 处理唤起选择文件的点击事件
|
||
*/
|
||
function onEvokeClick() {
|
||
handleChoose()
|
||
}
|
||
|
||
/**
|
||
* @description 选择文件,内置拦截选择操作
|
||
*/
|
||
function handleChoose(index?: number) {
|
||
if (props.disabled) return
|
||
const { beforeChoose } = props
|
||
|
||
// 选择图片前的钩子
|
||
if (beforeChoose) {
|
||
beforeChoose({
|
||
fileList: uploadFiles.value,
|
||
resolve: (isPass: boolean) => {
|
||
isPass && onChooseFile(index)
|
||
}
|
||
})
|
||
} else {
|
||
onChooseFile(index)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @description 移除文件
|
||
* @param {Object} file 上传的文件
|
||
* @param {Number} index 删除
|
||
*/
|
||
function handleRemove(file: UploadFileItem) {
|
||
uploadFiles.value.splice(
|
||
uploadFiles.value.findIndex((item) => item.uid === file.uid),
|
||
1
|
||
)
|
||
emit('change', {
|
||
fileList: uploadFiles.value
|
||
})
|
||
emit('remove', { file })
|
||
emitFileList()
|
||
}
|
||
|
||
function removeFile(index: number) {
|
||
const { beforeRemove } = props
|
||
const intIndex: number = index
|
||
const file = uploadFiles.value[intIndex]
|
||
if (beforeRemove) {
|
||
beforeRemove({
|
||
file,
|
||
index: intIndex,
|
||
fileList: uploadFiles.value,
|
||
resolve: (isPass: boolean) => {
|
||
isPass && handleRemove(file)
|
||
}
|
||
})
|
||
} else {
|
||
handleRemove(file)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 预览文件
|
||
* @param file
|
||
*/
|
||
function handlePreviewFile(file: UploadFileItem) {
|
||
uni.openDocument({
|
||
filePath: file.url,
|
||
showMenu: true
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 预览图片
|
||
* @param index
|
||
* @param lists
|
||
*/
|
||
function handlePreviewImage(index: number, lists: string[]) {
|
||
const { onPreviewFail } = props
|
||
uni.previewImage({
|
||
urls: lists,
|
||
current: lists[index],
|
||
fail() {
|
||
if (onPreviewFail) {
|
||
onPreviewFail({
|
||
index,
|
||
imgList: lists
|
||
})
|
||
} else {
|
||
uni.showToast({ title: '预览图片失败', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 预览视频
|
||
* @param index
|
||
* @param lists
|
||
*/
|
||
function handlePreviewVieo(index: number, lists: UploadFileItem[]) {
|
||
const { onPreviewFail } = props
|
||
// #ifdef MP-WEIXIN
|
||
uni.previewMedia({
|
||
current: index,
|
||
sources: lists.map((file) => {
|
||
return {
|
||
url: file.url,
|
||
type: 'video',
|
||
poster: file.thumb
|
||
}
|
||
}),
|
||
fail() {
|
||
if (onPreviewFail) {
|
||
onPreviewFail({
|
||
index,
|
||
imgList: []
|
||
})
|
||
} else {
|
||
uni.showToast({ title: '预览视频失败', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
// #endif
|
||
|
||
// #ifndef MP-WEIXIN
|
||
videoPreview.value?.open({ url: lists[index].url, poster: lists[index].thumb, title: lists[index].name })
|
||
// #endif
|
||
}
|
||
|
||
function onPreviewImage(file: UploadFileItem) {
|
||
const { beforePreview, reupload } = props
|
||
const fileList = deepClone(uploadFiles.value)
|
||
const index: number = fileList.findIndex((item) => item.url === file.url)
|
||
const imgList = fileList.filter((file) => isImage(file)).map((file) => file.url)
|
||
const imgIndex: number = imgList.findIndex((item) => item === file.url)
|
||
if (reupload) {
|
||
handleChoose(index)
|
||
} else {
|
||
if (beforePreview) {
|
||
beforePreview({
|
||
file,
|
||
index,
|
||
fileList: fileList,
|
||
imgList: imgList,
|
||
resolve: (isPass: boolean) => {
|
||
isPass && handlePreviewImage(imgIndex, imgList)
|
||
}
|
||
})
|
||
} else {
|
||
handlePreviewImage(imgIndex, imgList)
|
||
}
|
||
}
|
||
}
|
||
|
||
function onPreviewVideo(file: UploadFileItem) {
|
||
const { beforePreview, reupload } = props
|
||
const fileList = deepClone(uploadFiles.value)
|
||
const index: number = fileList.findIndex((item) => item.url === file.url)
|
||
const videoList = fileList.filter((file) => isVideo(file))
|
||
const videoIndex: number = videoList.findIndex((item) => item.url === file.url)
|
||
if (reupload) {
|
||
handleChoose(index)
|
||
} else {
|
||
if (beforePreview) {
|
||
beforePreview({
|
||
file,
|
||
index,
|
||
imgList: [],
|
||
fileList,
|
||
resolve: (isPass: boolean) => {
|
||
isPass && handlePreviewVieo(videoIndex, videoList)
|
||
}
|
||
})
|
||
} else {
|
||
handlePreviewVieo(videoIndex, videoList)
|
||
}
|
||
}
|
||
}
|
||
|
||
function onPreviewFile(file: UploadFileItem) {
|
||
const { beforePreview, reupload } = props
|
||
const fileList = deepClone(uploadFiles.value)
|
||
const index: number = fileList.findIndex((item) => item.url === file.url)
|
||
if (reupload) {
|
||
handleChoose(index)
|
||
} else {
|
||
if (beforePreview) {
|
||
beforePreview({
|
||
file,
|
||
index,
|
||
imgList: [],
|
||
fileList,
|
||
resolve: (isPass: boolean) => {
|
||
isPass && handlePreviewFile(file)
|
||
}
|
||
})
|
||
} else {
|
||
handlePreviewFile(file)
|
||
}
|
||
}
|
||
}
|
||
|
||
function isVideo(file: UploadFileItem) {
|
||
return (file.name && isVideoUrl(file.name)) || isVideoUrl(file.url)
|
||
}
|
||
|
||
function isImage(file: UploadFileItem) {
|
||
return (file.name && isImageUrl(file.name)) || isImageUrl(file.url)
|
||
}
|
||
</script>
|
||
<style lang="scss" scoped>
|
||
@import './index.scss';
|
||
</style>
|