封装原因
- 项目总会有一些奇奇怪怪的需求,但是当前项目使用的UI库的上传组件又无法满足需要
- 上传后文件的展示有特殊的要求
- 上传前、上传中、上传后要有特殊处理
- 满足大多数基本的文件上传功能
- 使用的是uniapp提供的API实现的上传功能,无需依赖第三方UI库
- 根据项目需求更改、新增功能代码(修改方便)
- ...
效果与功能
满足大多数基本的文件上传功能
组件参数
| 属性名 | 描述 | 示例 |
|---|---|---|
| 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函数