uniapp 封装上传文件组件

494 阅读9分钟

封装原因

  • 项目总会有一些奇奇怪怪的需求,但是当前项目使用的UI库的上传组件又无法满足需要
  • 上传后文件的展示有特殊的要求
  • 上传前、上传中、上传后要有特殊处理
  • 满足大多数基本的文件上传功能
  • 使用的是uniapp提供的API实现的上传功能,无需依赖第三方UI库
  • 根据项目需求更改、新增功能代码(修改方便)
  • ...

效果与功能

满足大多数基本的文件上传功能

image.png

组件参数

属性名描述示例
v-model:fileList数据双向绑定的上传的文件列表Array:[{}]
url上传的服务器地址(必传)字符串:'pmccapi.wsandos.com//common/fil…'
accept上传的文件类型字符串:'all'、'file'、'media'、'image'、'video'
multiple是否支持多选Boolean:true
maxCount最大上传数量Number:9
maxSize限制单个文件的上传大小,单位MB字符串或数字:'5'、5
maxSizeAll限制上传的所有文件的总大小,单位MB字符串或数字:'10'、10
sourceType图片和视频选择的来源Array:['album', 'camera']
compressed当accept为video时生效:是否压缩视频Boolean:true
camera当accept为video时生效:使用前置还是后置摄像头字符串:'back'、'front'
maxDuration当accept为video时生效:拍摄视频最长拍摄时间,单位秒Number:60
extension根据文件拓展名过滤Array:['.zip','.exe','.js']
isEditable文件上传组件是否可编辑(操作)Boolean:true
fileBoxStyle展示文件盒子样式对象:{}
fileItemStyle展示文件样式对象:{}
immediateUpload是否立即上传文件到服务器Boolean:true
showProgress是否显示上传进度Boolean:true
isDrag是否让文件可拖动排序Boolean:true

参数解释

accept

  • media和file只支持微信小程序
  • all只支持H5和微信小程序

sourceType

  • 当accept为image、video、media、all时生效
  • album 从相册选图,camera 使用相机
  • 如需直接开相机或直接选相册,请只使用一个选项

maxDuration

在不同平台的效果不一样,自行查看

extension

  • 暂只支持文件后缀名,例如['.zip','.exe','.js'],不支持application/msword等类似值
  • 支持场景1:accept为image并且使用平台为H5
  • 支持场景2:accept为video并且使用平台为H5
  • 支持场景3:accept为file并且使用平台为微信小程序
  • 支持场景4:accept为all并且使用平台为H5

isEditable

  • 当编辑/上传文件时值为true
  • 当只是需要展示文件时值为false

fileBoxStyle

展示所有文件的外层盒子的样式:比如一行展示多少个文件、背景色等等

fileItemStyle

展示文件的盒子的样式:比如文件大小、圆角等等

immediateUpload

  • 选完文件后是否立即上传
  • 如果false,需父组件 ref 调用 uploadPending() 才会上传所有已选的文件

封装代码

<template>
	<div class="file-box" :style="fileBoxStyle">
		<div
			class="file-item"
			:class="{
				'is-sort-ghost-source': sortDragActive && sortDragIndex === index,
			}"
			:style="[fileItemStyle, sortFlipStyleFor(item.uuid)]"
			v-for="(item, index) in fileList"
			:key="item.uuid || index"
			@click="clickFile(item)"
		>
			<image v-if="item.type == 'image'" :src="item.url" mode="scaleToFill" />
			<video
				v-if="item.type == 'video'"
				:src="item.url"
				:controls="true"
				:show-center-play-btn="false"
				object-fit="fill"
			></video>
			<text v-if="['document', 'other'].includes(item.type)">
				{{ item.name }}
			</text>
			<!-- 拖拽icon -->
			<view
				v-if="isDrag && props.isEditable && fileList.length > 1"
				class="sort-handle"
				@click.stop
				@touchstart.stop="onSortTouchStart($event, index)"
				@touchmove.stop.prevent="onSortTouchMove"
				@touchend.stop="onSortTouchEnd"
				@touchcancel.stop="onSortTouchEnd"
				@mousedown.stop="onSortMouseDown($event, index)"
			>
				<slot name="sort-handle" :index="index" :item="item">
					<text class="sort-handle-icon">≡</text>
				</slot>
			</view>
			<div
				class="file-item-delete"
				@click.stop="deleteFile(item.uuid)"
				v-if="props.isEditable"
			>
				X
			</div>
		</div>
		<div v-if="canAdd" class="file-add-wrap" @click="selectFile">
			<slot name="add-box">
				<div class="add-box"> + </div>
			</slot>
		</div>
	</div>

	<!-- 拖拽跟手幽灵层 -->
	<view
		v-if="sortDragActive && sortDraggingUuid"
		class="sort-drag-ghost"
		:style="sortGhostRootStyle"
	>
		<image
			v-if="sortGhostItem && sortGhostItem.type === 'image'"
			class="sort-drag-ghost-img"
			:src="sortGhostItem.url"
			mode="aspectFill"
		/>
		<view v-else-if="sortGhostItem" class="sort-drag-ghost-fallback">
			<text class="sort-drag-ghost-fallback-text">{{
				sortGhostFallbackText
			}}</text>
		</view>
	</view>

	<div
		class="tip-overlay"
		:style="{ padding: `${placeholderHeight}px 0 60rpx 0` }"
		v-if="isTip"
	>
		<div class="tip-title">以下文件上传失败</div>
		<div class="tip-box">
			<div
				class="tip-box-item"
				v-for="(tipFile, index) in tipFileList"
				:key="index"
			>
				<div class="tip-box-item-reasons">{{ tipFile.tipReasons }}</div>
				<div class="tip-media-box">
					<div
						class="tip-media-item"
						v-for="(item, index) in tipFile.tipMediaFileList"
						:key="index"
					>
						<image
							v-if="item.type == 'image'"
							:src="item.url"
							mode="scaleToFill"
						/>
						<video
							v-if="item.type == 'video'"
							:src="item.url"
							:controls="true"
							:show-center-play-btn="false"
							object-fit="fill"
						></video>
					</div>
				</div>
				<div
					class="tip-other-box"
					v-for="(item, index) in tipFile.tipOtherFileList"
					:key="index"
				>
					{{ item.name }}
				</div>
			</div>
		</div>
		<div class="tip-close" @click="isTip = false">关闭</div>
	</div>

	<div class="video-popup" v-if="videoPopupShow">
		<video :src="videoPopupUrl" controls></video>
		<div class="video-popup-close" @click="videoPopupShow = false">关闭</div>
	</div>

	<myProgress
		v-if="props.showProgress"
		:state="state"
		:currentSize="progressCurrentSize"
		:totalSize="progressTotalSize"
		:isFailed="isFailed"
		@closeProgress="closeProgress"
	></myProgress>
</template>

<script setup>
	import {
		ref,
		onMounted,
		onUnmounted,
		computed,
		watch,
		nextTick,
		getCurrentInstance,
	} from 'vue';
	import { onLoad } from '@dcloudio/uni-app';
	const { proxy } = getCurrentInstance();

	const props = defineProps({
		url: {
			type: String,
			required: true,
		},
		accept: {
			type: String,
			default: 'image',
		},
		multiple: {
			type: Boolean,
			default: true,
		},
		maxCount: {
			type: Number,
			default: 9,
		},
		maxSize: {
			type: [String, Number],
			default: '',
		},
		maxSizeAll: {
			type: [String, Number],
			default: '',
		},
		sourceType: {
			type: Array,
			default: ['album', 'camera'],
		},
		compressed: {
			type: Boolean,
			default: true,
		},
		camera: {
			type: String,
			default: 'back',
		},
		maxDuration: {
			type: Number,
			default: 9,
		},
		extension: {
			type: Array,
			default: [],
		},
		isEditable: {
			type: Boolean,
			default: true,
		},
		fileBoxStyle: {
			type: Object,
			default: {},
		},
		fileItemStyle: {
			type: Object,
			default: {},
		},
		immediateUpload: {
			type: Boolean,
			default: true,
		},
		showProgress: {
			type: Boolean,
			default: true,
		},
		isDrag: {
			type: Boolean,
			default: false,
		},
	});

	defineExpose({
		uploadPending,
	});

	onMounted(() => {
		geStatusBarHeight();
		getNavBarHeight();
	});

	// 获取 顶部导航栏高度 开始
	let statusBarHeight = ref(0);
	let navBarHeight = ref(0);
	// 计算占位盒子高度
	const placeholderHeight = computed(() => {
		return statusBarHeight.value + navBarHeight.value;
	});
	// 获取状态栏高度
	function geStatusBarHeight() {
		// #ifdef H5
		statusBarHeight.value = 0;
		// #endif

		// #ifdef MP-WEIXIN || APP-PLUS
		statusBarHeight.value = uni.getWindowInfo().statusBarHeight;
		// #endif
	}
	// 获取导航栏高度
	function getNavBarHeight() {
		// #ifdef MP-WEIXIN
		const menuButtonInfo = uni.getMenuButtonBoundingClientRect();
		navBarHeight.value =
			menuButtonInfo.height +
			(menuButtonInfo.top - uni.getWindowInfo().statusBarHeight) * 2 +
			2;
		// #endif

		// #ifdef APP-PLUS
		if (uni.getDeviceInfo().osName == 'android') {
			navBarHeight.value = 48;
		} else {
			navBarHeight.value = 44;
		}
		// #endif

		// #ifdef H5
		navBarHeight.value = 44;
		// #endif
	}
	// 获取 顶部导航栏高度 结束

	let fileList = defineModel('fileList'); // 文件列表

	// 是否可以添加文件
	const canAdd = computed(() => {
		if (!props.isEditable) return false;
		return props.multiple
			? fileList.value.length < props.maxCount
			: fileList.value.length === 0;
	});

	let allFileSize = 0; // 总文件大小
	watch(
		() => fileList.value.length,
		() => {
			handleFileList();
			renewAllFileSize();
		},
		{
			immediate: true,
		},
	);
	// 处理文件列表: 处理文件列表中的文件类型和名称
	function handleFileList() {
		if (fileList.value.length > 0) {
			for (let index = 0; index < fileList.value.length; index++) {
				const element = fileList.value[index];
				let data = {
					...element,
					type: element.type || getFileType(element.url),
					name: element.name || element.url.split('/').pop(),
					requiredUpload: element.requiredUpload || false,
				};
				data.uuid = data.uuid || getUniqueKey(data.type, data.name);
				fileList.value[index] = data;
			}
			// console.log('处理后的fileList', fileList.value);
		}
	}
	// 更新总文件大小
	function renewAllFileSize() {
		allFileSize = 0;
		fileList.value.forEach((item) => {
			allFileSize += item.size || 0;
		});
		// console.log('总文件大小:', allFileSize);
	}

	// 进度条相关
	import myProgress from './myProgress.vue';
	let state = ref(false); // 进度条组件状态
	let isFailed = ref(false); // 是否失败
	// 关闭进度条
	function closeProgress() {
		state.value = false;
		isFailed.value = false;
	}
	let progressTotalSize = ref(0); // 总上传数据大小
	// 计算需要上传的数据总大小
	function getTotalSize(file) {
		let totalSize = 0;
		if (props.multiple) {
			file.forEach((item) => {
				totalSize += item.size;
			});
		} else {
			totalSize = file.size;
		}
		progressTotalSize.value = totalSize;
	}
	let progressCurrentSize = ref(0); // 当前上传数据大小

	const platform = uni.getAppBaseInfo().uniPlatform;

	// 点击选择文件
	function selectFile() {
		switch (platform) {
			case 'web':
				switch (props.accept) {
					case 'image':
						choiceImageInWeb();
						break;
					case 'video':
						choiceVideoInWeb();
						break;
					case 'media':
						choiceMediaInWeb();
						break;
					case 'file':
						choiceFileInWeb();
						break;
					case 'all':
						choiceAllInWeb();
						break;
				}
				break;
			case 'mp-weixin':
				switch (props.accept) {
					case 'image':
						choiceImageInMini();
						break;
					case 'video':
						choiceVideoInMini();
						break;
					case 'media':
						choiceMediaInMini();
						break;
					case 'file':
						choiceFileInMini();
						break;
					case 'all':
						choiceAllInMini();
						break;
				}
				break;
			case 'app':
				switch (props.accept) {
					case 'image':
						choiceImageInApp();
						break;
					case 'video':
						choiceVideoInApp();
						break;
					case 'media':
						choiceMediaInApp();
						break;
					case 'file':
						choiceFileInApp();
						break;
					case 'all':
						choiceAllInApp();
						break;
				}
				break;
		}
	}

	// 选择图片 web端
	function choiceImageInWeb() {
		uni.chooseImage({
			count: props.multiple ? props.maxCount : 1,
			sourceType: props.sourceType,
			extension: props.extension.length ? props.extension : [''],
			success: (res) => {
				let arr = res.tempFiles.map((item) => {
					return {
						type: 'image',
						url: item.path,
						size: item.size,
						name: item.name || '',
					};
				});
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择视频 web端
	function choiceVideoInWeb() {
		uni.chooseVideo({
			sourceType: props.sourceType,
			compressed: props.compressed,
			maxDuration: props.maxDuration,
			camera: props.camera,
			extension: props.extension.length ? props.extension : [''],
			success: (res) => {
				let arr = [
					{
						type: 'video',
						url: res.tempFilePath,
						size: res.size,
						name: res.name || '',
					},
				];
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择媒体文件 web端
	function choiceMediaInWeb() {
		uni.showToast({
			title: 'web端暂不支持选择媒体文件',
			icon: 'none',
		});
	}
	// 选择file web端
	function choiceFileInWeb() {
		uni.showToast({
			title: 'web端暂不支持选择file文件',
			icon: 'none',
		});
	}
	// 选择全部文件 web端
	function choiceAllInWeb() {
		uni.chooseFile({
			count: props.multiple ? props.maxCount : 1,
			sourceType: props.sourceType,
			extension: props.extension.length ? props.extension : [''],
			success: (res) => {
				let arr = res.tempFiles.map((item) => {
					return {
						type: getFileType(item.name),
						url: item.path,
						size: item.size,
						name: item.name,
					};
				});
				afterRead(arr);
			},
			fail: () => {},
		});
	}

	// 选择图片 微信小程序端
	function choiceImageInMini() {
		uni.chooseImage({
			count: props.multiple ? props.maxCount : 1,
			sourceType: props.sourceType,
			success: (res) => {
				let arr = res.tempFiles.map((item) => {
					return {
						type: 'image',
						url: item.path,
						size: item.size,
						name: item.name || '',
					};
				});
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择视频 微信小程序端
	function choiceVideoInMini() {
		uni.chooseVideo({
			sourceType: props.sourceType,
			compressed: props.compressed,
			maxDuration: props.maxDuration,
			camera: props.camera,
			success: (res) => {
				let arr = [
					{
						type: 'video',
						url: res.tempFilePath,
						size: res.size,
						name: res.name || '',
					},
				];
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择媒体文件 微信小程序端
	function choiceMediaInMini() {
		uni.chooseMedia({
			count: props.multiple ? props.maxCount : 1,
			sourceType: props.sourceType,
			maxDuration: props.maxDuration,
			camera: props.camera,
			success: (res) => {
				let arr = res.tempFiles.map((item) => {
					return {
						type: item.fileType,
						url: item.tempFilePath,
						size: item.size,
						name: '',
					};
				});
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择file 微信小程序端
	function choiceFileInMini() {
		wx.chooseMessageFile({
			count: props.multiple ? props.maxCount : 1,
			type: 'file',
			extension: props.extension.length ? props.extension : [''],
			success: (res) => {
				let arr = res.tempFiles.map((item) => {
					return {
						type: getFileType(item.name),
						url: item.path,
						size: item.size,
						name: item.name,
					};
				});
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择全部文件 微信小程序端
	function choiceAllInMini() {
		wx.chooseMessageFile({
			count: props.multiple ? props.maxCount : 1,
			success: (res) => {
				let arr = res.tempFiles.map((item) => {
					return {
						type: getFileType(item.name),
						url: item.path,
						size: item.size,
						name: item.name,
					};
				});
				afterRead(arr);
			},
			fail: () => {},
		});
	}

	// 选择图片 app端
	function choiceImageInApp() {
		uni.chooseImage({
			count: props.multiple ? props.maxCount : 1,
			sourceType: props.sourceType,
			success: (res) => {
				let arr = res.tempFiles.map((item) => {
					return {
						type: 'image',
						url: item.path,
						size: item.size,
						name: item.name || '',
					};
				});
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择视频 app端
	function choiceVideoInApp() {
		uni.chooseVideo({
			sourceType: props.sourceType,
			compressed: props.compressed,
			maxDuration: props.maxDuration,
			camera: props.camera,
			success: (res) => {
				let arr = [
					{
						type: 'video',
						url: res.tempFilePath,
						size: res.size,
						name: res.name || '',
					},
				];
				afterRead(arr);
			},
			fail: () => {},
		});
	}
	// 选择媒体文件 app端
	function choiceMediaInApp() {
		uni.showToast({
			title: 'app端暂不支持选择媒体文件',
			icon: 'none',
		});
	}
	// 选择file app端
	function choiceFileInApp() {
		uni.showToast({
			title: 'app端暂不支持选择file文件',
			icon: 'none',
		});
	}
	// 选择全部文件 app端
	function choiceAllInApp() {
		uni.showToast({
			title: 'app端暂不支持选择全部文件',
			icon: 'none',
		});
	}

	// 上传文件之后
	async function afterRead(file) {
		try {
			if (props.immediateUpload && props.showProgress) {
				state.value = true;
			}
			if (props.multiple) {
				await uploadFiles(file);
			} else {
				await uploadFile(file[0]);
			}
			if (tipFileList.value.length) {
				isTip.value = true;
			}
		} catch {
			if (props.immediateUpload) {
				isFailed.value = true;
			}
		}
		if (props.immediateUpload && props.showProgress) {
			state.value = false;
		}
	}

	/**
	 * 延迟上传时由父组件调用:上传当前列表中带有 requiredUpload 的项(已上传的不会重复传)
	 */
	async function uploadPending() {
		const pending = fileList.value.filter((item) => item.requiredUpload);
		if (!pending.length) {
			return;
		}
		try {
			if (props.showProgress) {
				state.value = true;
				progressCurrentSize.value = 0;
			}
			if (pending.length === 1 && !props.multiple) {
				getTotalSize(pending[0]);
			} else {
				getTotalSize(pending);
			}
			for (let i = 0; i < pending.length; i++) {
				const item = pending[i];
				const res = await upload(item.url, pending.length > 1 ? i : 0);
				const idx = fileList.value.findIndex((x) => x.uuid === item.uuid);
				if (idx !== -1) {
					fileList.value[idx] = {
						...fileList.value[idx],
						url: res.image_url,
						requiredUpload: false,
					};
				}
			}
		} catch (e) {
			isFailed.value = true;
			throw e;
		} finally {
			if (props.showProgress) {
				state.value = false;
			}
		}
	}

	// 单文件上传
	async function uploadFile(file) {
		try {
			let { type, size, url, name } = file;

			// 检查文件大小是否超过限制
			let maxSize = props.maxSize * 1024 * 1024;
			if (maxSize && size > maxSize) {
				setTimeout(() => {
					uni.showToast({
						title: `最多上传 ${maxSize / 1024 / 1024} MB 的文件`,
						icon: 'none',
					});
				}, 1500);
				if (props.immediateUpload) {
					isFailed.value = true;
				}
				return;
			}

			// 检查所有文件的总大小是否超过限制
			let maxSizeAll = props.maxSizeAll * 1024 * 1024;
			if (maxSizeAll && size > maxSizeAll) {
				setTimeout(() => {
					uni.showToast({
						title: `最多上传 ${maxSizeAll / 1024 / 1024} MB 的文件`,
						icon: 'none',
					});
				}, 1500);
				if (props.immediateUpload) {
					isFailed.value = true;
				}
				return;
			}

			if (!props.immediateUpload) {
				fileList.value = [
					{
						type,
						name,
						size,
						url,
						uuid: getUniqueKey(type, name),
						requiredUpload: true,
					},
				];
				return;
			}

			progressCurrentSize.value = 0;
			getTotalSize(file);

			// 开始上传
			let res = await upload(url, 0);
			fileList.value = [
				{
					type,
					name,
					size,
					url: res.image_url,
					uuid: getUniqueKey(type, name),
					requiredUpload: false,
				},
			];
		} catch (error) {
			console.log('单文件上传失败', error);
		}
	}
	// 上传错误提示相关
	const isTip = ref(false);
	const tipFileList = ref([]);
	// 多文件上传
	async function uploadFiles(file) {
		tipFileList.value = [];
		try {
			// 检查所有的文件大小是否超过限制
			let maxSize = props.maxSize * 1024 * 1024;
			if (maxSize) {
				let unqualifiedArr = []; // 不符合限制的文件
				let qualifiedArr = []; // 符合限制的文件
				file.forEach((item) => {
					if (item.size > maxSize) {
						unqualifiedArr.push(item);
					} else {
						qualifiedArr.push(item);
					}
				});
				if (unqualifiedArr.length) {
					let tipMediaFileList = [];
					let tipOtherFileList = [];
					unqualifiedArr.forEach((item) => {
						if (item.type == 'image' || item.type == 'video') {
							tipMediaFileList.push(item);
						} else {
							tipOtherFileList.push(item);
						}
					});
					tipFileList.value.push({
						tipReasons: `失败原因 : 文件大小不能超过 ${maxSize / 1024 / 1024} MB`,
						tipMediaFileList,
						tipOtherFileList,
					});
				}
				file = qualifiedArr;
			}

			// 检查所有文件的总大小是否超过限制
			let maxSizeAll = props.maxSizeAll * 1024 * 1024;
			if (maxSizeAll) {
				let allSize = allFileSize;
				let arrIndex = -1;
				let unqualifiedArr = []; // 不符合限制的文件
				let qualifiedArr = []; // 符合限制的文件
				for (let index = 0; index < file.length; index++) {
					const size = file[index].size;
					allSize += size;
					if (allSize > maxSizeAll) {
						arrIndex = index;
						break;
					}
				}
				if (arrIndex == -1) {
					qualifiedArr = file;
				} else {
					unqualifiedArr = file.slice(arrIndex, file.length);
					qualifiedArr = file.slice(0, arrIndex);
				}

				if (unqualifiedArr.length) {
					let tipMediaFileList = [];
					let tipOtherFileList = [];
					unqualifiedArr.forEach((item) => {
						if (item.type == 'image' || item.type == 'video') {
							tipMediaFileList.push(item);
						} else {
							tipOtherFileList.push(item);
						}
					});
					tipFileList.value.push({
						tipReasons: `失败原因 : 所有文件的大小不能超过 ${maxSizeAll / 1024 / 1024} MB`,
						tipMediaFileList,
						tipOtherFileList,
					});
				}
				file = qualifiedArr;
			}

			if (!props.immediateUpload) {
				if (file.length) {
					let arr = file.map((element) => ({
						type: element.type,
						name: element.name,
						size: element.size,
						url: element.url,
						uuid: getUniqueKey(element.type, element.name),
						requiredUpload: true,
					}));
					fileList.value = [...fileList.value, ...arr];
				}
				return;
			}

			// 开始上传
			if (file.length) {
				progressCurrentSize.value = 0;
				getTotalSize(file);
				let arr = [];
				for (let index = 0; index < file.length; index++) {
					let element = file[index];
					let res = await upload(element.url, index);
					arr.push({
						type: element.type,
						name: element.name,
						size: element.size,
						url: element.url,
						url: res.image_url,
						uuid: getUniqueKey(element.type, element.name),
						requiredUpload: false,
					});
				}
				fileList.value = [...fileList.value, ...arr];
			}
		} catch (error) {
			console.log('多文件上传失败', error);
		}
	}
	// 获取文件类型:image 图片,video 视频,document 文档,other 其他
	function getFileType(params) {
		let fileType = '';
		const imageExtensions = [
			'jpg',
			'jpeg',
			'png',
			'gif',
			'bmp',
			'tiff',
			'tif',
			'webp',
			'heic',
			'ico',
			'svg',
			'eps',
			'ai',
			'raw',
			'psd',
			'xcf',
			'tga',
			'dds',
		];
		const videoExtensions = [
			'mp4',
			'mkv',
			'avi',
			'mov',
			'wmv',
			'flv',
			'webm',
			'mpg',
			'mpeg',
			'3gp',
			'm4v',
			'vob',
			'rmvb',
			'f4v',
			'ts',
			'ogv',
			'mxf',
			'asf',
			'swf',
		];
		const documentExtensions = [
			'doc',
			'xls',
			'ppt',
			'pdf',
			'docx',
			'xlsx',
			'pptx',
		];
		const extension = params.split('.').pop().toLowerCase();
		// console.log('文件后缀', extension);
		if (imageExtensions.includes(extension)) {
			fileType = 'image';
		} else if (videoExtensions.includes(extension)) {
			fileType = 'video';
		} else if (documentExtensions.includes(extension)) {
			fileType = 'document';
		} else {
			fileType = 'other';
		}
		return fileType;
	}
	// 生成一个唯一标识:type+name+10位随机数
	function getUniqueKey(type, name) {
		return type + name + Math.ceil(Math.random() * 10000000000);
	}

	// 发送请求
	function upload(url, index) {
		return new Promise((resolve, reject) => {
			var uploadTask = uni.uploadFile({
				url: props.url,
				filePath: url,
				name: 'file',
				formData: {},
				header: {
					Authorization: `Bearer ${uni.getStorageSync('token')}`,
				},
				success: (res) => {
					const data = JSON.parse(res.data);
					// console.log('后端接口返回结果', data);
					if (data.code === 200) {
						resolve(data.data);
					} else {
						reject(data);
						uni.showToast({
							icon: 'error',
							title: data.msg,
						});
					}
				},
				fail: (e) => {
					uni.showToast({
						icon: 'error',
						title: '上传失败',
					});
					reject(e);
				},
			});
			let lastProgress = 0; // 上次上传进度
			let progressOfThisUpload = 0; // 当前上传进度
			uploadTask.onProgressUpdate((res) => {
				// console.log('已经上传的数据长度', res.totalBytesSent);
				if (index) {
					if (lastProgress) {
						progressOfThisUpload = res.totalBytesSent;
						progressCurrentSize.value += progressOfThisUpload - lastProgress;
						lastProgress = progressOfThisUpload;
					} else {
						lastProgress = res.totalBytesSent;
						progressCurrentSize.value += lastProgress;
					}
				} else {
					progressCurrentSize.value = res.totalBytesSent;
				}
			});
		});
	}

	// 点击文件
	function clickFile(data) {
		if (Date.now() < sortSuppressClickUntil) {
			return;
		}
		switch (data.type) {
			case 'image':
				preview(data.url);
				break;
			case 'video':
				videoPopupShow.value = true;
				videoPopupUrl.value = data.url;
				break;
			case 'document':
			case 'other':
				saveAndOpenDocument(data);
				break;
		}
	}
	// 点击图片 放大
	function preview(url) {
		try {
			let urls = [];
			let index = 0;
			if (props.multiple) {
				fileList.value.forEach((item) => {
					if (item.type == 'image') {
						urls.push(item.url);
					}
				});
				index = urls.indexOf(url);
			} else {
				urls = [url];
				index = 0;
			}
			uni.previewImage({
				current: index,
				urls,
			});
		} catch (error) {
			console.log('图片放大失败', error);
		}
	}

	const videoPopupShow = ref(false); // 视频弹窗
	const videoPopupUrl = ref(''); // 视频弹窗url

	// 保存文档并打开预览
	function saveAndOpenDocument(data) {
		let { url, name, type, requiredUpload } = data;
		if (platform === 'web') {
			let a = document.createElement('a');
			a.href = url;
			a.download = name;
			document.body.appendChild(a);
			a.click();
			document.body.removeChild(a);
			if (type == 'document') {
				if (!requiredUpload) {
					uni.showToast({
						title: '请先上传文件后再预览',
						icon: 'none',
					});
					return;
				}
				const fileUrl = encodeURIComponent(url);
				// // 使用 Microsoft Office Online 预览
				window.open(
					`https://view.officeapps.live.com/op/view.aspx?src=${fileUrl}`,
					'_blank',
				);
				// // 使用 Google Docs Viewer 预览
				// // window.open(`https://docs.google.com/gview?url=${fileUrl}&embedded=true`, '_blank');
			}
		} else {
			uni.downloadFile({
				url: url,
				filePath: `${uni.env.USER_DATA_PATH}/${name}`, // 指定保存的路径并指定文件名
				success: (res) => {
					if (res.statusCode === 200) {
						if (type == 'document') {
							uni.openDocument({
								filePath: res.filePath,
								showMenu: true,
							});
						}
					}
				},
			});
		}
	}
	// 删除文件
	function deleteFile(uuid) {
		fileList.value = fileList.value.filter((item) => item.uuid != uuid);
	}

	// 拖拽排序(触摸目标为手柄时,整段手势仍落在该节点,适合小程序/H5/App)
	const sortDragActive = ref(false);
	const sortDragIndex = ref(-1);
	const sortDraggingUuid = ref('');
	const sortGhostLeft = ref(0);
	const sortGhostTop = ref(0);
	const sortGhostMetrics = ref({ w: 120, h: 120 });
	const sortFlipStyles = ref({});
	let sortFlipToken = 0;
	let sortFromIndex = -1;
	let sortStartX = 0;
	let sortStartY = 0;
	let sortSuppressClickUntil = 0;
	/** 节流定时器(小程序部分运行环境无 requestAnimationFrame,统一用 setTimeout) */
	let sortHitTimer = 0;
	let sortLastClientX = 0;
	let sortLastClientY = 0;

	const sortGhostItem = computed(() => {
		if (!sortDraggingUuid.value) {
			return null;
		}
		return (
			fileList.value.find((i) => i.uuid === sortDraggingUuid.value) || null
		);
	});

	const sortGhostFallbackText = computed(() => {
		const it = sortGhostItem.value;
		if (!it) {
			return '';
		}
		if (it.type === 'video') {
			return '视频';
		}
		if (it.type === 'document') {
			return '文档';
		}
		if (it.type === 'other') {
			return '文件';
		}
		return '文件';
	});

	const sortGhostRootStyle = computed(() => ({
		position: 'fixed',
		left: `${sortGhostLeft.value}px`,
		top: `${sortGhostTop.value}px`,
		width: `${sortGhostMetrics.value.w}px`,
		height: `${sortGhostMetrics.value.h}px`,
		zIndex: 10050,
		pointerEvents: 'none',
	}));

	function sortFlipStyleFor(uuid) {
		return sortFlipStyles.value[uuid] || {};
	}

	function sortUpdateGhostPosition(x, y) {
		sortGhostLeft.value = x - sortGhostMetrics.value.w / 2;
		sortGhostTop.value = y - sortGhostMetrics.value.h / 2;
	}

	function sortRefreshGhostMetrics(clientX, clientY) {
		uni
			.createSelectorQuery()
			.in(proxy)
			.selectAll('.file-item')
			.boundingClientRect((rects) => {
				if (!rects || !rects.length) {
					return;
				}
				const idx = fileList.value.findIndex(
					(i) => i.uuid === sortDraggingUuid.value,
				);
				const r = idx >= 0 ? rects[idx] : null;
				if (r && r.width > 0 && r.height > 0) {
					sortGhostMetrics.value = { w: r.width, h: r.height };
				}
				sortUpdateGhostPosition(clientX, clientY);
			})
			.exec();
	}

	function runSortFlip(beforeSnap) {
		nextTick(() => {
			nextTick(() => {
				uni
					.createSelectorQuery()
					.in(proxy)
					.selectAll('.file-item')
					.boundingClientRect((afterRects) => {
						if (
							!afterRects ||
							!beforeSnap ||
							afterRects.length !== beforeSnap.length
						) {
							return;
						}
						const nextMap = {};
						afterRects.forEach((r, i) => {
							const u = fileList.value[i]?.uuid;
							if (u) {
								nextMap[u] = r;
							}
						});
						const styles = {};
						beforeSnap.forEach((br) => {
							const nr = nextMap[br.uuid];
							if (!nr) {
								return;
							}
							const dx = br.left - nr.left;
							const dy = br.top - nr.top;
							if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
								return;
							}
							styles[br.uuid] = {
								transform: `translate(${dx}px, ${dy}px)`,
								transition: 'none',
							};
						});
						if (!Object.keys(styles).length) {
							return;
						}
						sortFlipToken += 1;
						const tk = sortFlipToken;
						sortFlipStyles.value = { ...styles };
						setTimeout(() => {
							if (tk !== sortFlipToken) {
								return;
							}
							const anim = {};
							Object.keys(styles).forEach((u) => {
								anim[u] = {
									transform: 'translate(0,0)',
									transition:
										'transform 0.28s cubic-bezier(0.25, 0.8, 0.25, 1)',
								};
							});
							sortFlipStyles.value = anim;
							setTimeout(() => {
								if (tk !== sortFlipToken) {
									return;
								}
								sortFlipStyles.value = {};
							}, 320);
						}, 24);
					})
					.exec();
			});
		});
	}

	function onSortTouchStart(e, index) {
		if (!props.isDrag || !props.isEditable || fileList.value.length < 2) {
			return;
		}
		const t = e.touches && e.touches[0];
		if (!t) {
			return;
		}
		sortFromIndex = index;
		sortDragIndex.value = index;
		sortStartX = t.clientX ?? t.pageX;
		sortStartY = t.clientY ?? t.pageY;
		sortDragActive.value = false;
		sortDraggingUuid.value = '';
		sortGhostMetrics.value = { w: 120, h: 120 };
		sortFlipToken += 1;
		sortFlipStyles.value = {};
	}

	function onSortTouchMove(e) {
		if (!props.isDrag || sortFromIndex < 0) {
			return;
		}
		const t = e.touches && e.touches[0];
		if (!t) {
			return;
		}
		const x = t.clientX ?? t.pageX;
		const y = t.clientY ?? t.pageY;
		if (!sortDragActive.value) {
			if (Math.abs(x - sortStartX) + Math.abs(y - sortStartY) < 12) {
				return;
			}
			sortDragActive.value = true;
			const cur = fileList.value[sortDragIndex.value];
			if (cur?.uuid) {
				sortDraggingUuid.value = cur.uuid;
			}
			sortRefreshGhostMetrics(x, y);
		} else if (sortDraggingUuid.value) {
			sortUpdateGhostPosition(x, y);
		}
		sortLastClientX = x;
		sortLastClientY = y;
		if (sortHitTimer) {
			return;
		}
		sortHitTimer = setTimeout(() => {
			sortHitTimer = 0;
			sortHitTestAndReorder(sortLastClientX, sortLastClientY);
		}, 16);
	}

	function sortHitTestAndReorder(x, y) {
		if (sortDragIndex.value < 0) {
			return;
		}
		uni
			.createSelectorQuery()
			.in(proxy)
			.selectAll('.file-item')
			.boundingClientRect((rects) => {
				if (!rects || !rects.length) {
					return;
				}
				let hit = -1;
				for (let i = 0; i < rects.length; i++) {
					const r = rects[i];
					if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
						hit = i;
						break;
					}
				}
				if (hit < 0 || hit === sortDragIndex.value) {
					return;
				}
				const beforeSnap = rects.map((r, i) => ({
					left: r.left,
					top: r.top,
					uuid: fileList.value[i].uuid,
				}));
				const list = [...fileList.value];
				const from = sortDragIndex.value;
				if (from < 0 || from >= list.length) {
					return;
				}
				const [moved] = list.splice(from, 1);
				list.splice(hit, 0, moved);
				fileList.value = list;
				sortDragIndex.value = hit;
				runSortFlip(beforeSnap);
			})
			.exec();
	}

	function onSortTouchEnd() {
		if (sortDragActive.value) {
			sortSuppressClickUntil = Date.now() + 400;
		}
		sortDragActive.value = false;
		sortFromIndex = -1;
		sortDragIndex.value = -1;
		sortDraggingUuid.value = '';
		sortFlipToken += 1;
		sortFlipStyles.value = {};
		if (sortHitTimer) {
			clearTimeout(sortHitTimer);
			sortHitTimer = 0;
		}
	}

	// #ifdef H5
	let sortMouseMoveHandler = null;
	function onSortMouseDown(e, index) {
		if (!props.isDrag || !props.isEditable || fileList.value.length < 2) {
			return;
		}
		e.preventDefault();
		onSortTouchStart(
			{ touches: [{ clientX: e.clientX, clientY: e.clientY }] },
			index,
		);
		const cur = fileList.value[index];
		if (cur?.uuid) {
			sortDraggingUuid.value = cur.uuid;
		}
		sortDragActive.value = true;
		sortRefreshGhostMetrics(e.clientX, e.clientY);
		sortMouseMoveHandler = (ev) => {
			onSortTouchMove({
				touches: [{ clientX: ev.clientX, clientY: ev.clientY }],
			});
		};
		window.addEventListener('mousemove', sortMouseMoveHandler);
		window.addEventListener('mouseup', onSortMouseUp, { once: true });
	}
	function onSortMouseUp() {
		if (sortMouseMoveHandler) {
			window.removeEventListener('mousemove', sortMouseMoveHandler);
			sortMouseMoveHandler = null;
		}
		onSortTouchEnd();
	}
	onUnmounted(() => {
		if (sortMouseMoveHandler) {
			window.removeEventListener('mousemove', sortMouseMoveHandler);
			sortMouseMoveHandler = null;
		}
	});
	// #endif
	// #ifndef H5
	function onSortMouseDown() {}
	// #endif
</script>

<style lang="scss" scoped>
	.file-box {
		width: 100%;
		display: flex;
		align-items: center;
		flex-wrap: wrap;
		gap: 15rpx;
	}
	.file-add-wrap {
		flex: 1;
	}
	.file-item {
		width: 200rpx;
		height: 200rpx;
		border-radius: 20rpx;
		overflow: hidden;
		position: relative;
		image,
		video {
			width: 100%;
			height: 100%;
		}
	}
	.sort-handle {
		position: absolute;
		left: 0;
		top: 0;
		padding: 4rpx 10rpx;
		border-radius: 10rpx;
		display: flex;
		align-items: center;
		justify-content: center;
		z-index: 4;
		background: rgba(0, 0, 0, 0.35);
		touch-action: none;

		.sort-handle-icon {
			color: #fff;
			font-size: 32rpx;
		}
	}

	.file-item.is-sort-ghost-source {
		opacity: 0.22;
		transform: scale(0.94);
		transition:
			opacity 0.2s ease,
			transform 0.2s ease;
		z-index: 2;
	}

	.sort-drag-ghost {
		border-radius: 20rpx;
		overflow: hidden;
		box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.22);
		background: #fff;
		transform: scale(1.04);

		.sort-drag-ghost-img {
			width: 100%;
			height: 100%;
			display: block;
		}
		.sort-drag-ghost-fallback {
			width: 100%;
			height: 100%;
			background: #f0f0f0;
			display: flex;
			align-items: center;
			justify-content: center;
			padding: 12rpx;
			box-sizing: border-box;
			.sort-drag-ghost-fallback-text {
				font-size: 22rpx;
				color: #666;
				text-align: center;
			}
		}
	}

	.file-item-delete {
		position: absolute;
		top: 4rpx;
		right: 4rpx;
		display: flex;
		justify-content: center;
		align-items: center;
		width: 40rpx;
		height: 40rpx;
		border-radius: 50%;
		background: #f8382a;
		color: #fff;
		font-size: 20rpx;
	}

	.add-box {
		display: flex;
		justify-content: center;
		align-items: center;
		flex-direction: column;
		background: #ccc;
		width: 200rpx;
		height: 200rpx;
		border-radius: 20rpx;
		overflow: hidden;
		font-size: 100rpx;
	}

	.tip-overlay {
		position: fixed;
		top: 0;
		left: 0;
		background: rgba(0, 0, 0, 0.5);
		backdrop-filter: blur(10px);
		z-index: 9999;
		width: 100vw;
		height: 100vh;
		display: flex;
		justify-content: center;
		align-items: center;
		flex-direction: column;
		.tip-title {
			font-weight: 700;
			font-size: 28rpx;
			color: #44aeed;
			margin: 20rpx;
		}
		.tip-box {
			flex: 1;
			margin: 20rpx;
			overflow-y: auto;
			.tip-box-item {
				.tip-box-item-reasons {
					font-weight: 700;
					font-size: 28rpx;
					color: #44aeed;
					margin-bottom: 20rpx;
				}
				.tip-media-box {
					display: flex;
					align-items: center;
					flex-wrap: wrap;
					.tip-media-item {
						width: 200rpx;
						height: 200rpx;
						border-radius: 20rpx;
						overflow: hidden;
						margin: 15rpx;
						image,
						video {
							width: 100%;
							height: 100%;
						}
					}
				}
				.tip-other-box {
					padding: 10rpx 20rpx;
					background: #ccc;
					margin: 20rpx 0;
					width: 100%;
					font-weight: 700;
					font-size: 28rpx;
					color: #44aeed;
					// 1行显示
					white-space: nowrap;
					overflow: hidden;
					text-overflow: ellipsis;
					// direction: rtl; // 文本从右到左
				}
			}
		}
		.tip-close {
			margin: 20rpx;
			padding: 10rpx 20rpx;
			border-radius: 20rpx;
			background: #44aeed;
			color: #fff;
			font-size: 28rpx;
		}
	}

	.video-popup {
		position: fixed;
		top: 0;
		left: 0;
		background: rgba(0, 0, 0, 0.5);
		backdrop-filter: blur(10px);
		z-index: 9999;
		width: 100vw;
		height: 100vh;
		display: flex;
		justify-content: center;
		align-items: center;
		flex-direction: column;
		.video-popup-close {
			margin: 20rpx;
			padding: 10rpx 20rpx;
			border-radius: 20rpx;
			background: #44aeed;
			color: #fff;
			font-size: 28rpx;
		}
	}
</style>

注意点

  • 在页面中使用的myProgressBar组件是一个进度条组件,文章地址:juejin.cn/spost/73463…
  • upload函数:上传成功里的逻辑需要根据项目后端接口的实际情况进行修改
  • image_url:这个参数需要根据项目后端接口返回的完整url地址的属性名进行修改

页面使用

<myUpload
    			url="https://pmcctestapi.wsandos.com/common/file/upload"
    			accept="all"
    			:multiple="true"
    			v-model:fileList="fileList"
    		></myUpload>
                    
    let fileList = ref([
			{
				name: '测试图片',
				url: 'https://pic.20988.xyz/2024-08-29/1724899264-903459-preview.jpg',
			},
			{
				name: '测试视频',
				url: 'https://pmcctestapi.wsandos.com/uploads/20250322/1bef64feb00a0e602a1889419e5c715b.mp4',
			},
			{
				name: '测试excel',
				url: 'https://pmcctestapi.wsandos.com/uploads/20250317/7a1a857745663355e0ad5baee21522c7.xlsx',
			},
			{
				name: '测试pdf',
				url: 'https://pmcctestapi.wsandos.com/uploads/20250317/e22fb0b88dc9f735dce1d82395d5fc23.pdf',
			},
		]);

注意

当数据回显的时候(比如,查看表单详情、审核失败重新填写表单数据)fileList中的数据最少要有一个url字段,否则无法正常回显和操作

回显逻辑可查看源码中的handleFileList函数