Vue3 + TS 实现批量拖拽上传 文件和文件夹

一、html 代码:

代码中的表格引入了 vxe-table 插件

<Tag /> 是自己封装的说明组件

表格列表这块我使用了插槽来增加扩展性,可根据自己需求,在组件外部做调整

<template>
    <div class="dragUpload">
        <el-dialog v-model="data.visible"
            width="600px"
            center
            :draggable="draggable"
            :destroy-on-close="true"
            :close-on-click-modal="false"
            :close-on-press-escape="false"
            :before-close="closeDialogFn"
        >
            <template #header>
                <h3>{{ data.title || '拖拽上传' }}</h3>
            </template>
            <!-- 顶部:自定义插槽 -->
            <slot name="topCustomize"></slot>

            <!-- 文件上传区域 -->
            <div class="drag-box"
                v-if="fileData.step === 1"
                @dragover="handleDragOver"
                @dragleave="handleDragOver"
                @drop="handleDrop"
            >
                <div class="div-text" >
                    <div class="drag-tip">
                        <div class="flex-content">
                            <i class="icon_fileUpload f-s60 color-409"></i>
                        </div>
                        拖拽文件至此区域上传
                    </div>
                    <div class="btn-wrap">
                        <el-button v-if="singleFile" @click="toUploadFile">选取文件</el-button>
                        <el-button v-if="batchfolder" @click="toUploadFloder">选取文件夹</el-button>
                        <input
                            v-if="singleFile"
                            :style="{ display: 'none' }"
                            type="file"
                            ref="fileUploadRef"
                            @change="handleFileChange"
                            multiple
                        />
                        <input
                            :style="{ display: 'none' }"
                            type="file"
                            ref="fileUploadFloderRef"
                            @change="handleFloderChange"
                            webkitdirectory
                            multiple
                        />
                    </div>
                </div>
            </div>

            <!-- 根据外部传参,需要展示列表 -->
            <div v-if="fileData.step !== 1">
                <!-- 添加插槽,表格可在外部自定义展示 -->
                <slot name="slotTable">
                    <div class="h300">
                        <vxe-table
                            :data="fileData.table"
                            height="100%"
                            :checkbox-config="{
                                showHeader: true,
                                trigger: 'cell'
                            }"
                        >
                            <template #empty>
                                <div class="no-data-box">
                                    <i class="icon_noData"></i>
                                    <div class="m-t6">暂无数据</div>
                                </div>
                            </template>
                            <!-- 外部传入,动态列表展示 -->
                            <template v-for="item in data.table">
                                <vxe-column :min-width="item.minWidth || 100" :title="item.title">
                                    <template #default="{ row }">
                                        {{ row[item.key] ? row[item.key] : '--' }} {{ item.unit ? item.unit : '' }}
                                    </template>
                                </vxe-column>
                            </template>

                            <vxe-column width="100" title="操作" :visible="fileData.table.length > 1">
                                <template #default="{ $rowIndex, row }">
                                    <el-tooltip
                                        content="移除"
                                        placement="top"
                                        :hide-after="0"
                                    >
                                        <a class="icon_delete f-s18"
                                            href="javscript:"
                                            @click="handleDelete($rowIndex, row)">
                                        </a>
                                    </el-tooltip>
                                </template>
                            </vxe-column>
                        </vxe-table>
                    </div>
                </slot>
            </div>

            <!-- 自定义说明插槽:只在拖拽上传下展示 -->
            <template v-if="fileData.step === 1">
                <slot name="uploadTag"></slot>
            </template>
            <!-- 自定义说明插槽:只在有表格下展示 -->
            <template v-if="fileData.step !== 1">
                <slot name="tableTag">
                    <Tag class="m-t12"
                        :content="`上传文件总大小不能超过 ${data.fileSize} MB,当前文件总大小 ${fileData.allSize} MB`"
                    />
                </slot>
            </template>

            <!-- 底部按钮 -->
            <template #footer v-if="!autoUpload || fileData.step !== 1">
                <div class="dialog-footer">
                    <el-button @click="closeDialogFn">取消</el-button>
                    <!-- 开启手动上传则显示 -->
                    <el-button v-if="fileData.step === 1" type="primary" @click="handleUploadToServer">上传</el-button>
                    <!-- 需要再次提交的场景则显示,例:超出文件大小限制 -->
                    <el-button v-if="fileData.step !== 1 && uploadGoBeyond" type="primary" @click="handleUploadToServer(true)">提交</el-button>
                </div>
            </template>

        </el-dialog>
    </div>
</template>

二、js 代码:

目前支持校验上传的文件类型有三种:

image:图片类型;video:视频类型;excel:表格类型(拓展了任意类型,但新增的类型中消息提示需要在外部传入)

这块主要思路是:将文件夹判断后进行递归,获取出文件夹中的文件出来,最后类似单个文件上传,然后将文件流进行遍历 append 进创建的 FormData 对象。具体方法看:readFiles() 和 handleUploadToServer()

添加拓展:

1、指定文件类型中的单个文件,限制上传的大小,单位字节 MB

2、限制指定类型的上传个数

3、限制指定某种类型总大小

3、图片分辨率校验

4、视频宽高比例校验

5、动态超出列表生成

<script lang="ts" setup>
import { reactive, ref, getCurrentInstance } from 'vue';

const { proxy }: any = getCurrentInstance();

// 该组件有四个插槽可使用
// 1、顶部:自定义插槽  <slot name="topCustomize">
// 2、表格:自定义表格插槽  <slot name="slotTable">
// 3、拖拽上传下展示的:说明标签  <slot name="uploadTag">
// 4、表格下展示的:说明标签  <slot name="tableTag">

// 组件参数配置
const props = defineProps({
    // 重要参数配置
    data: {
        type: Object,
        default: () => ({
            // visible: false, // 是否显示对话框
            // title: '', // 对话框标题,bubu不传默认:‘拖拽上传’
            // fileSize: 100, // 文件大小限制,单位字节 MB,不传或为0则不限制
            /*** type 对象为空或者不传,则不限制上传类型 */
            // type: {   
            //     自定义上传的文件类型 = image:图片类型;video:视频类型;excel:表格类型;pdf:pdf文件类型
            //     有需要可以自己追加上传类型,pdf: ['pdf'], 提示消息则需要自己在外部添加传入
            //     image: ['png', 'jpg', 'jpeg'],
            //     video: ['mp4', 'avi', 'mov'],
            //     excel: ['xlsx', 'xls']
            // },
            //
            /*** 指定文件类型中的单个文件,限制上传的大小,单位字节 MB */
            // singleFileSize: {
            //     image: 5,
            //     video: 10,
            //     excel: 10
            // },
            //
            /*** 指定文件类型中的全部文件,限制上传的大小,单位字节 MB */
            // allFileSize: {
            //     image: 5,
            //     video: 10,
            //     excel: 10
            // },
            //
            /*** 限制指定类型的上传个数 */
            // image[0]:最少上传个数,image[1]:最多上传个数;不限制最少或最多则传: [0, 10] 或者 [10, 0], 0 则代表不限制
            // singleFileNumber: {
            //     image: [0, 10],
            //     video: [0, 10],
            //     excel: [0, 10]
            // },
            //
            /*** 图片分辨率校验,不传或为0则不限制  */
            // imageResolution: {
            //     minWidth: 0,
            //     minHeight: 0,
            //     maxWidth: 2000,
            //     maxHeight: 2000
            // },
            //
            /*** 视频宽高比例校验,不传或为0则不限制  */
            // videoProportion: {
            //     minWidth: 0,
            //     minHeight: 0,
            //     maxWidth: 2000,
            //     maxHeight: 2000
            // }
            /*** 内部需要展示的列表,例:可展示文件大小超出限制的列表  */
            // table: [
                // {
                //     title: '文件名', // 列标题
                //     key: 'pathName0', //展示的字段名
                //     minWidth: '180', // 列宽,不传默认100
                //     unit: 'MB' // 单位,不传则不显示单位
                // }
            // ],
            //
            // deleteFileType: ['db'], // 将上传文件中指定的后缀类型删除掉,不参与上传判断,避免上传类型校验不过的问题
            // limit: 0, // 允许上传总文件的最大数量,0或不传默认无限制
        }),
    },
    // 自定义消息提示,不传则使用组件内默认提示
    message: {
        type: Object,
        default: () => {
            /*** 格式错误自定义提示:根据 type 来,但格式需要写成 ${'type中的key'},因组件内部代码采用:查找 ${} 进行替换;不传则使用组件内默认提示 */
            // formatMessage: {
            //     'image': '图片只支持上传:${image} 格式',
            //     'video': '视频只支持上传:${video} 格式',
            //     'excel': 'Excel文件只支持上传:${excel} 格式'
            // },
            //
            /*** 单个文件大小超出限制,消息提示:用法同上 ***/
            // singleSizeMessage: {
            //     'image': '单个图片大小不能超过 ${image} MB',
            //     'video': '单个视频大小不能超过 ${video} MB'
            // },
            //
            /*** 单个文件类型数量限制,消息提示:用法同上 ***/
            // singleNumberMessage: {
            //     'image-min': '最少上传 ${image-min} 张图片',
            //     'video-min': '最少上传 ${video-min} 条视频',
            //     'pdf-max': '最多上传 ${pdf-max} 个 PDF文件'
            // },
            //
            /*** 指定文件类型总大小限制,消息提示 ***/
            // allSizeMessage: {
            //     'image': '图片文件总大小不能超过 ${image} MB',
            //     'video': '视频文件总大小不能超过 ${video} MB',
            //     'excel': 'Excel文件总大小不能超过 ${excel} MB',
            //     'pdf': 'PDF文件总大小不能超过 ${excel} MB'
            // }
        }
    },
    // 是否开启,文件超出总大小后的列表展示,默认不开启
    uploadGoBeyond: {
        type: Boolean,
        default: false
    },
    // 是否支持窗口拖拽,默认true
    draggable: {
        type: Boolean,
        default: true
    },
    // 是否自动上传文件,默认true
    autoUpload: {
        type: Boolean,
        default: true
    },
    // 是否支持打开 file 单文件上传,不传默认false
    singleFile: {
        type: Boolean,
        default: false
    },
    // 是否支持打开 folder 文件夹上传,不传默认true
    batchfolder: {
        type: Boolean,
        default: true
    }
});

/**
 * @param dragUploadAxiosFn 上传参数抛出,外部做处理,不与组件内部逻辑耦合
 * @param dragUploadErrorTable 超出限制后,展示的列表插槽抛出,外部做处理,不与组件内部逻辑耦合
 * @param dragUploadDeleteItem 删除文件列表项抛出,外部做处理,不与组件内部逻辑耦合
 */
const emit = defineEmits(['dragUploadAxiosFn', 'dragUploadErrorTable', 'dragUploadDeleteItem']);

const fileUploadRef = ref();
const fileUploadFloderRef = ref();

// 文件数据
const fileData: any = reactive({
    step: 1, // 步骤: 1:文件拖拽上传;2:文件超出提示
    uploadList: [], //上传的文件列表
    waitUploadList: [], //存储待上传的文件列表
    fileSizeList: [], //存储遍历出来文件里面所有的图片路径及大小
    table: [], //内部列表展示
    allSize: 0, //文件总大小 MB
    firstFileName: 'pathName0' //第一列字段:key
});

// 组件内部默认拥有的消息提示数据
const messageData: any = reactive({
    // 文件格式错误,消息提示
    formatMessage: {
        'image': '支持图片上传:${image} 格式',
        'video': '支持视频上传:${video} 格式',
        'excel': '支持Excel文件上传:${excel} 格式',
        'pdf': '文件类型支持上传:${pdf} 格式'
    },
    // 单个文件大小超出限制,消息提示
    singleSizeMessage: {
        'image': '单个图片大小不能超过 ${image} MB',
        'video': '单个视频大小不能超过 ${video} MB',
        'excel': '单个Excel文件大小不能超过 ${excel} MB',
        'pdf': '单个PDF文件大小不能超过 ${pdf} MB'
    },
    // 单个文件类型数量限制,消息提示
    singleNumberMessage: {
        'image-min': '最少上传 ${image-min} 张图片',
        'video-min': '最少上传 ${video-min} 条视频',
        'excel-min': '最少上传 ${excel-min} 个 Excel文件',
        'pdf-min': '最少上传 ${pdf-min} 个 PDF文件',
        'image-max': '最多上传 ${image-max} 张图片',
        'video-max': '最多上传 ${video-max} 条视频',
        'excel-max': '最多上传 ${excel-max} 个 Excel文件',
        'pdf-max': '最多上传 ${pdf-max} 个 PDF文件'
    },
    // 指定文件类型总大小限制,消息提示
    allSizeMessage: {
        'image': '图片文件总大小不能超过 ${image} MB',
        'video': '视频文件总大小不能超过 ${video} MB',
        'excel': 'Excel文件总大小不能超过 ${excel} MB',
        'pdf': 'PDF文件总大小不能超过 ${excel} MB'
    }
});

/*文件上传input*/
const toUploadFile = () => {
    fileUploadRef.value.click();
};

/*文件夹上传input*/
const toUploadFloder = () => {
    fileUploadFloderRef.value.click();
};

/*选择文件改变*/
const handleFileChange = (e: any) => {
    if (e.target.files) {
        let filesList: any = Array.from(e.target.files);

        filesList.forEach((item: any) => {
            let size = item.size / 1024 / 1024;
            fileData.allSize += size;

            let obj: any = getPath(item.name);
            changeFileSizeList(item, obj);

        });
        fileData.allSize = fileData.allSize.toFixed(2); // 文件总大小 MB
        fileData.waitUploadList = filesList;

        checkUploadFn(); //校验及上传
    }
	fileUploadRef.value.value = ''; //清空文件输入框的值,避免连续上传同一个文件时第二次无法触发到输入框的change事件
};

/*文件夹目录上传*/
const handleFloderChange = (e: any) => {
    if (e.target.files) {
        let filesList: any = Array.from(e.target.files);
        fileData.allSize = 0;
        filesList.forEach((item: any) => { 
            let size = item.size / 1024 / 1024;
            fileData.allSize += size;

            let obj: any = getPath(item.webkitRelativePath); // 通过路径获取名称方法
            changeFileSizeList(item, obj);
        });

        filesList.reverse(); // 反转数组,保证最先选择的文件排在最后面
        fileData.allSize = fileData.allSize.toFixed(2); // 文件总大小 MB
        fileData.waitUploadList = filesList;

        checkUploadFn(); //校验及上传
    }
	fileUploadFloderRef.value.value = ''; //清空文件输入框的值,避免连续上传同一个文件时第二次无法触发到输入框的change事件
};

// 拖放进入目标区域
const handleDragOver = (event: any) => {
    event.preventDefault();
};

// 拖拽放置
const handleDrop = async (event: any) => {
    event.preventDefault();
    const promises: any[] = [];

    for (const item of event.dataTransfer.items) {
        const entry: any = item.webkitGetAsEntry();
        if (entry.isDirectory && !props.batchfolder) {
            proxy.$message.error(`只支持文件上传,不支持文件夹上传!`);
            return;
        }
        if (!entry.isDirectory && !props.singleFile) {
            proxy.$message.error(`只支持文件夹上传,不支持单个文件上传!`);
            return;
        }

        promises.push(readFiles(entry));
    }
    const resultFilesArrays = await Promise.all(promises); // 等待所有文件读取完成
    fileData.waitUploadList = resultFilesArrays.flat();

    checkUploadFn(); //校验及上传
};

//文件读取完,针对文件的处理,校验和上传方法封装
const checkUploadFn = async () => {
    deleteFileTypeFn();

    let nextValue = await fileValidateFn(fileData.fileSizeList, fileData.waitUploadList);
    if (!nextValue) return; // 校验方法
    
    handleUploadToServer(); //上传文件到服务器
};

// 将上传文件中指定的后缀删除掉,不参与上传判断
const deleteFileTypeFn = () => {
    if (props.data.deleteFileType) {
        let deleteType = props.data.deleteFileType;
        fileData.fileSizeList = fileData.fileSizeList.filter((item: any) => !deleteType.includes(item.suffix));
        fileData.waitUploadList = fileData.waitUploadList.filter((item: any) => !deleteType.includes(item.suffix));
    }
};

/**
 * 文件校验函数
 *
 * @param fileSizeList 其他校验,如:文件类型、大小、数量等
 * @param waitUploadList 目前用来校验图片分辨率
 * @returns 验证通过返回true,否则返回false
 */
const fileValidateFn = async (fileSizeList: any, waitUploadList: any) => {
    //文件类型判断和格式限制
    if (props.data.type) {
        if (!testingFileType(fileSizeList)) return false;
    }

    // 指定文件类型:单个文件限制上传的大小,限制指定类型上传个数,指定文件类型总大小限制
    if (props.data.singleFileSize || props.data.singleFileNumber || props.data.allFileSize) {
        if (!appointFileTypeFn(fileSizeList)) return false;
    }

    //文件数量超出限制
    if (props.data.limit) {
        if (!fileLimit(fileSizeList)) return false;
    }

    //总文件大小超出限制
    if (props.data.fileSize) {
        //开启:超出总大小后的列表展示 
        if (props.uploadGoBeyond && !allFileSizeLimit(fileSizeList)) { 
            fileData.table = getGoBeyondTable(fileSizeList);
            fileData.step = 2;
            return false;
        }
        //关闭:执行逻辑
        if (!props.uploadGoBeyond && !allFileSizeLimit(fileSizeList)) { 
            return false;
        }
    }

    // 图片分辨率校验 和 视频宽高比例校验
    if (props.data.imageResolution || props.data.videoProportion) {
        if (!await imageOrVideoFn(waitUploadList)) return false;
    }

    return true;
};

// 指定文件类型:单个文件限制上传的大小,限制指定类型上传个数,指定文件类型总大小限制
const appointFileTypeFn = (fileSizeList: any) => {
    let singleFileSize = props.data.singleFileSize;
    let singleFileNumber = props.data.singleFileNumber;
    let allFileSize = props.data.allFileSize;

    let sizeObj: any = {}; //存储超出大小的文件
    let numberObj: any = {}; //存储各种类型文件的数量
    let allSizeObj: any = {}; //存储各种类型文件的总大小

    fileSizeList.forEach((item: any) => {

        if (singleFileSize && singleFileSize[item.type]) {
            let size = singleFileSize[item.type] * 1024 * 1024; // 转成 MB
            // 找出超出的文件,并分类型,存入 sizeObj[key] 中
            if (size < item.size) {
                sizeObj[item.type] = sizeObj[item.type] || [];
                sizeObj[item.type].push(item);
            }
        }

        if (singleFileNumber) {
            if (numberObj[item.type]) {
                numberObj[item.type].push(item);
            } else {
                numberObj[item.type] = [];
                numberObj[item.type].push(item);
            }
        }

        if (allFileSize) {
            if (allSizeObj[item.type]) {
                allSizeObj[item.type] += item.size;
            } else {
                allSizeObj[item.type] = item.size;
            }
        }
    });

    // 指定文件类型中的单个文件,限制上传的大小
    if (singleFileSize) {
        //消息提示操作
        let messageTemplates: any = messageData.singleSizeMessage;
        // 外部传入:自定义提示信息替换成外部传入的
        replaceMessage(messageTemplates, props.message?.singleSizeMessage);
    
        if (Object.keys(sizeObj).length) {
            let findKey: any = {}; // 找出需要提示的类型,例如:{ image: 5 } 表示图片类型超出限制了
            for (let key in sizeObj) {
                if (sizeObj.hasOwnProperty(key) && singleFileSize.hasOwnProperty(key)) {
                    findKey[key] = singleFileSize[key];
                }
            }

            let message: string = '';
            for (let key in findKey) {
                if (findKey.hasOwnProperty(key) && messageTemplates.hasOwnProperty(key)) {
                    let template = messageTemplates[key];
                    // 使用正则表达式匹配 ${} 内的内容,并进行替换
                    message += template.replace(/\$\{([^}]+)\}/g, (_: any, match: any) => findKey[match]) + ';';
                }
            }
            proxy.$message.error(message);

            //复原,防止继续上传时叠加
            fileData.fileSizeList = [];
            sizeObj = {}; 
            return false;
        }
    }

    // 限制指定类型上传个数
    if (singleFileNumber) {
        const keysList = Object.keys(singleFileNumber);
        let countObj: any = {}; // 存储需要校验的类型文件,例:{image-min: 8, pdf-max: 1}

        keysList.forEach((key: string) => {
            if (numberObj.hasOwnProperty(key)) {
                let num = numberObj[key].length;
                let range = singleFileNumber[key]; // 获取每种类型的限制范围
                if (range[0] && num < range[0]) {
                    countObj[`${key}-min`] = range[0];
                }
                if (range[1] && num > range[1]) {
                    countObj[`${key}-max`] = range[1];
                }
            }
        });

        //消息提示
        if (Object.keys(countObj).length) {
            let messageTemplates: any = messageData.singleNumberMessage;
            // 外部传入:自定义提示信息替换成外部传入的
            replaceMessage(messageTemplates, props.message?.singleNumberMessage);

            let message: string = '';
            for (let key in countObj) {
                if (countObj.hasOwnProperty(key) && messageTemplates.hasOwnProperty(key)) {
                    let template = messageTemplates[key];
                    // 使用正则表达式匹配 ${} 内的内容,并进行替换
                    message += template.replace(/\$\{([^}]+)\}/g, (_: any, match: any) => countObj[match]) + ';';
                }
            }
            proxy.$message.error(message);

            //复原,防止继续上传时叠加
            fileData.fileSizeList = [];
            numberObj = {}; 
            return false;
        }
    }

    // 指定文件类型总大小限制
    if (allFileSize) {
        const keysList = Object.keys(allFileSize);
        let checkObj: any = {}; // 存储需要校验的类型文件,例:{ image: 0.1, video: 1 }

        keysList.forEach((key: string) => {
            let propsAllSize = allFileSize[key] * 1024 * 1024; // 转成 MB
            let allSize = allSizeObj[key];

            if (allSize > propsAllSize) {
                checkObj[key] = allFileSize[key];
            }
        });
        
        if (Object.keys(checkObj).length) {
            let messageTemplates: any = messageData.allSizeMessage;
            // 外部传入:自定义提示信息替换成外部传入的
            replaceMessage(messageTemplates, props.message?.allSizeMessage);

            let message: string = '';
            for (let key in checkObj) {
                if (checkObj.hasOwnProperty(key) && messageTemplates.hasOwnProperty(key)) {
                    let template = messageTemplates[key];
                    // 使用正则表达式匹配 ${} 内的内容,并进行替换
                    message += template.replace(/\$\{([^}]+)\}/g, (_: any, match: any) => checkObj[match]) + ';';
                }
            }
            proxy.$message.error(message);

            //复原,防止继续上传时叠加
            fileData.fileSizeList = [];
            allSizeObj = {}; 
            return false;
        }
    }
    
    return true;
};

/**
 * 验证文件类型是否为允许的上传的类型
 * @param fileSizeList 文件列表
 * @param type  image:图片类型;video:视频类型;excel:表格类型;pdf:pdf文件类型
 * @param formatMessage 外部传入的自定义提示信息,替换默认提示信息或者新增进入提示信息
 * @returns 如果文件类型不符合要求,返回 false;否则返回 true
 */
const testingFileType = (fileSizeList: any) => {
    let type: any = props.data.type;
    if (Object.keys(type).length === 0) return true; // type 对象为空或者不传,则不限制上传类型

    // 使用 Object.values 获取对象值的数组,然后使用 flatMap 合并所有数组
    const typeList = Object.values(type).flatMap((array: any) => array);
    //过滤出不符合要求的文件类型
    let filterList: any = fileSizeList.filter((item: any) => !typeList.includes(item.suffix));

    if (filterList.length) {
        // 消息提示
        let messageTemplates: any = messageData.formatMessage;
        // 外部传入:自定义提示信息替换成外部传入的
        replaceMessage(messageTemplates, props.message?.formatMessage);

        let message: string = '';
        for (let key in type) {
            if (type.hasOwnProperty(key) && messageTemplates.hasOwnProperty(key)) {
                let template = messageTemplates[key];
                // 使用正则表达式匹配 ${} 内的内容,并进行替换
                message += template.replace(/\$\{([^}]+)\}/g, (_: any, match: any) => type[match].join(', ')) + ';';
            }
        }
        proxy.$message.error(message);

        //错误的文件,不管里面有没有正确格式的,一律清除
        fileData.fileSizeList = [];
        fileData.allSize = 0;
        return false;
    }

    return true;
};

//文件数量超出限制
const fileLimit = (fileSizeList: any) => {
    if (fileSizeList.length > props.data.limit) {
        proxy.$message.error(`文件数量不能超过 ${props.data.limit} 个,请重新上传!`);
        fileData.fileSizeList = [];
        return false;
    }
    return true;
};

//总文件大小超出限制
const allFileSizeLimit = (fileSizeList: any) => {
    let allSize = fileSizeList.reduce((accumulator: any, currentValue: any) => {
        if (currentValue) {
            return accumulator + currentValue.size;
        }
        return accumulator;
    }, 0);

    let fileSize = props.data.fileSize * 1024 * 1024;
    fileData.allSize = (allSize / 1024 / 1024).toFixed(2); //存储文件总大小 MB

    if (allSize > fileSize) {
        proxy.$message.error(`文件总大小不能超过 ${props.data.fileSize} MB,请重新上传!`);
        emit('dragUploadErrorTable', fileData);
        return false;
    }
    return true;
};

// 图片分辨率校验 和 视频宽高比例校验 Start
const imageOrVideoFn = async (waitUploadList: any) => {
    let imageError: any = []; //存储分辨率不符合要求的图片文件列表
    let imageRules = props.data.imageResolution;
    let videoError: any = []; //存储宽高比不符合要求的视频文件列表
    let videoRules = props.data.videoProportion;

    if (imageRules) {
        let imageList = waitUploadList.filter((item: any) => item.customizeType === 'image'); //筛选出图片类型的文件列表
        if (!imageList.length) return true;

        for (let i = 0; i < imageList.length; i++) {
            await imageValidFn(imageList[i], imageError, imageRules);
        }
    }

    if (videoRules) {
        let videoList = waitUploadList.filter((item: any) => item.customizeType === 'video'); //筛选选出视频类型的文件列表
        if (!videoList.length) return true;

        for (let i = 0; i < videoList.length; i++) {
            await videoValidFn(videoList[i], videoError, videoRules);
        }
    }

    if (imageError.length) {
        let message: string = '';
        if (imageRules.minWidth && !imageRules.maxWidth) {
            message = `图片分辨率需在 ${imageRules.minWidth} x ${imageRules.minHeight} 以上,请重新上传!`;
        } else if (!imageRules.minWidth && imageRules.maxWidth) {
            message = `图片分辨率需在 ${imageRules.maxWidth} x ${imageRules.maxHeight} 以下,请重新上传!`;
        } else if (imageRules.minWidth && imageRules.maxWidth) {
            message = `图片分辨率需在 ${imageRules.minWidth} x ${imageRules.minHeight} ~ ${imageRules.maxWidth} x ${imageRules.maxHeight} 之间,请重新上传!`;
        }
        proxy.$message.error(message);
        return false;
    }

    if (videoError.length) {
        let message: string = '';
        if (videoRules.minWidth && !videoRules.maxWidth) {
            message = `视频宽高比需在 ${videoRules.minWidth} :${videoRules.minHeight} 以上,请重新上传!`;
        } else if (!videoRules.minWidth && videoRules.maxWidth) {
            message = `视频宽高比需在 ${videoRules.maxWidth} :${videoRules.maxHeight} 以下,请重新上传!`;
        } else if (videoRules.minWidth && videoRules.maxWidth) {
            message = `视频宽高比需在 ${videoRules.minWidth} :${videoRules.minHeight} ~ ${videoRules.maxWidth} :${videoRules.maxHeight} 之间,请重新上传!`;
        }
        proxy.$message.error(message);
        return false;
    }
    return true;
};

const imageValidFn = (fileItem: any, errorList: any, rules: any) => {
    return new Promise((resolve) => {
        const reader = new FileReader();
        reader.onload = (event) => {
            const img: any = new Image();
            img.onload = () => {
                if (img.width < rules.minWidth || img.width > rules.maxWidth ||
                    img.height < rules.minHeight || img.height > rules.maxHeight) {
                    errorList.push(fileItem); // 图片分辨率不符合要求
                    resolve(false);
                } else {
                    resolve(true);
                }
            };
            img.onerror = () => {
                errorList.push(fileItem); // 图片加载失败,视为分辨率错误
                resolve(false);
            };
            img.src = (event.target as any).result;
        };
        reader.onerror = () => {
            errorList.push(fileItem); // 文件读取失败,视为分辨率错误
            resolve(false);
        };
        reader.readAsDataURL(fileItem);
    });
};

const videoValidFn = (file: any, videoError: any, rules: any) => {
    return new Promise((resolve) => {
        const video = document.createElement('video');
        video.src = URL.createObjectURL(file);
        video.onloadstart = () => {
            // 获取视频的元数据
            video.addEventListener('loadedmetadata', () => {
                // 计算宽高比
                const aspectRatio = video.videoWidth / video.videoHeight;
                // 定义宽高比的范围(9:16 到 16:9)
                let { minWidth, minHeight, maxWidth, maxHeight } = rules;
                // 传值限制的宽高比范围
                const minAspectRatio = minWidth / minHeight;
                const maxAspectRatio = maxWidth / maxHeight;

                // 检查宽高比是否在范围内
                if (aspectRatio >= minAspectRatio && aspectRatio <= maxAspectRatio) {
                    resolve(true);
                } else {
                    videoError.push(file); // 视频宽高比不符合要求
                    resolve(false);
                }

                // 释放对象URL
                URL.revokeObjectURL(video.src);
            });
        };

        video.onerror = () => { //无法加载视频以检查其分辨率
            videoError.push(file); // 视频宽高比不符合要求
            resolve(false);
        };
    });
};
// 图片分辨率校验 和 视频宽高比例校验 End

// 操作数据:文件超出后,展示的列表
const getGoBeyondTable = (fileSizeList: any) => {
    // 遍历,相同第一列为一项,size累加
    let result: any = fileSizeList.reduce((accumulator: any, current: any) => {
        if (accumulator[current[fileData.firstFileName]]) { //如果已经存在,则累加size
            accumulator[current[fileData.firstFileName]].size += current.size;
        } else {
            accumulator[current[fileData.firstFileName]] = { ...current };
        }
        return accumulator;
    }, {});
    
    // 将结果对象转换回数组
    result = Object.values(result);
    // 处理size为MB单位
    result.forEach((item: any) => {
        item.size = (item.size / 1024 / 1024).toFixed(2);
    });
    return result;
};

//移除超出文件列表的项
const handleDelete = (rowIndex: number, row: any) => {
    fileData.table.splice(rowIndex, 1);

    fileData.allSize = (fileData.allSize - row.size).toFixed(2); //更新总大小MB

    emit('dragUploadDeleteItem', fileData.allSize);

    fileData.fileSizeList = fileData.fileSizeList.filter((item: any) => item[fileData.firstFileName] !== row[fileData.firstFileName]);

    fileData.waitUploadList = fileData.waitUploadList.filter((item: any) => item[fileData.firstFileName] !== row[fileData.firstFileName]);
};

/*请求上传到服务器*/
const handleUploadToServer = (click?: boolean) => {
    let autoUpload: boolean = props.autoUpload;
    if (click) autoUpload = true; //手动上传
    if (!autoUpload) return; //不自动上传,则不执行

    fileData.uploadList = fileData.waitUploadList;
    // 再次提交时验证文件大小是否超出限制
    if (click && fileData.allSize > props.data.fileSize) {
        proxy.$message.error(`文件总大小不能超过 ${props.data.fileSize} MB`);
        return;
    }

    // let formData = new FormData();
    // fileData.uploadList.forEach((item: any) => {
    //     formData.append(`${item.filePathName}`, item);
    // });
    // // 遍历FormData对象并打印其内容:查看FormData对象数据是否正确
    // for (let [key, value] of formData.entries()) {
    //     console.log(`${key}: ${value}`);
    // }
    fileData.allSize = 0;
    fileData.fileSizeList = [];
    fileData.waitUploadList = [];
    if (props.data.uploadGoBeyond) props.data.title = ''; //重置标题
    fileData.step = 1;

    emit('dragUploadAxiosFn', fileData.uploadList); //上传参数抛出,外部做操作,不在组件做耦合
};

//此方法:如果是文件夹,则会递归调用自己,所以最后都会走 else 的逻辑
const readFiles = async (item: any) => {
    if (item.isDirectory) {
        // 是一个文件夹
        const directoryReader = item.createReader();
        // readEntries是一个异步方法
        const entries: any[] = await new Promise((resolve, reject) => {
            directoryReader.readEntries(resolve, reject);
        });
        let files: any = [];
        for (const entry of entries) {
            const resultFiles: any = await readFiles(entry);
            files = files.concat(resultFiles);
        }
        return files;
    } else {
        // file也是一个异步方法
        const file = await new Promise((resolve, reject) => {
            item.file(resolve, reject);
        });
        let obj: any = getPath(item.fullPath); //通过路径获取名称方法
        changeFileSizeList(file, obj);

        return [file];
    }
};

//更改 fileData.fileSizeList 的值:公共
const changeFileSizeList = (file: any, obj: any) => {
    try {
        let index = file.name.lastIndexOf('.');
        let suffix = file.name.substring(index + 1); //获取后缀
        let type = file.type.split('/')[0]; // 获取文件类型
        if (['xlsx', 'xls'].includes(suffix)) {
            type = 'excel'; //更改文件类型
        }
        if (['pdf'].includes(suffix)) {
            type = 'pdf'; //更改文件类型
        }

        file.filePathName = obj.filePathName; //添加路径名称
        file[fileData.firstFileName] = obj.pathObj[fileData.firstFileName]; //添加第一列文件名
        file.pathObj = obj.pathObj;
        file.suffix = suffix; //类型后缀
        file.customizeType = type; //给文件添加自定义类型,方便后续处理

        fileData.fileSizeList.push({ //添加图片路径、大小、名称
            filePathName: obj.filePathName,
            size: file.size,
            suffix: suffix,
            type: type,
            ...obj.pathObj
        });
    } catch {
        proxy.$message.error(`文件读取路径失败,请重新上传文件!`);
    }
};

//通过路径获取名称方法:公共
const getPath = (path: string) => {
    try {
        let filePathName: any = path;  // 传给后端的全路径
        if (path.startsWith('/')) { // 如果路径以斜杠开头,则删除第一个斜杠
            filePathName = path.slice(1);
        }

        let parts = filePathName.split('/'); // 路径分割成数组
        let pathObj: any = {}; // 存储每个部分
        for (let i = 0; i < parts.length; i++) {
            if (parts[i] !== '') { // 跳过空字符串(如果路径以 / 开头或结尾)
                pathObj['pathName' + (i)] = parts[i];
            }
        }

        return {
            filePathName: filePathName,
            pathObj: pathObj
        }
    } catch {
        proxy.$message.error(`文件读取路径失败,请重新上传文件!`);
    }
};

//自定义消息提示替换方法:公共
const replaceMessage = (message: any, propsMessage: any) => {
    if (propsMessage && Object.keys(propsMessage).length) {
        for (const key in propsMessage) {
            if (message.hasOwnProperty(key)) {
                // 如果 message 中存在该键,则替换其值
                message[key] = propsMessage[key];
            } else {
                // 不存在则增加进去
                message[key] = propsMessage[key];
            }
        }
    }
};

//关闭事件
const closeDialogFn = () => {
    if (fileData.step === 1) {
        if (props.data.uploadGoBeyond) props.data.title = ''; //重置标题
        props.data.visible = false; //关闭弹窗
        return;
    }
    proxy.$messageBox({
        title: '关闭',
        message: '关闭后不会保留您所做的更改,确定关闭吗?',
        callback: (value: string) => {
            //confirm=确认;cancel=取消
            if (value === 'confirm') {
                fileData.step = 1;
                if (props.data.uploadGoBeyond) props.data.title = ''; //重置标题
                props.data.visible = false; //关闭弹窗
            }
        }
    });
};
</script>

三、css 代码:

<style lang="scss" scoped>
.drag-box {
    position: relative;
    .progress-bar {
        position: absolute;
        z-index: 100;
        width: 100%;
        top: 0;
        left: 0px;
        right: 0px;
        bottom: -5px;
        display: flex;
        justify-content: center;
        align-items: center;
        background-color: rgba(255, 255, 255, 0.8);
        :deep(.el-progress.el-progress--line) {
            width: 100%;
            margin-left: 10px;
        }
    }

    .uploaded-list-wrap {
        max-height: 200px;
        overflow-y: auto;
        .uploaded-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            cursor: pointer;
            margin-bottom: 3px;
            .text-content {
                width: 80%;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            }
            .icon {
                width: 25px;
                height: 25px;
            }
            .success-icon {
                display: block;
            }
            .delete-icon {
                display: none;
            }
            &:hover {
                .success-icon {
                    display: none;
                }
                .delete-icon {
                    display: block;
                }
            }
        }
    }
}

.div-text {
    width: 100%;
    height: 250px;
    border: 1px dashed #409effc2;;
    border-radius: 10px;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    font-size: 18px;
    background-color: #cccccc1c;
    .click-txt {
        color: #409effc2;;
        cursor: pointer;
    }
    .btn-wrap {
        margin-top: 20px;
    }
}

.h300 {
    height: 300px;
}

.dragUpload {
    :deep(.el-dialog .el-dialog__header) {
        padding: 12px 30px;
        display: flex;
        justify-content: space-between;
        box-shadow: 0px 0px !important;
        border-radius: 5px 5px 0px 0px !important;
    }

    :deep(.el-dialog .el-dialog__body) {
        padding: 8px 30px 11px;
        width: 540px !important;
        min-width: 540px !important;
        max-width: 540px !important;
    }

    :deep(.el-dialog .el-dialog__footer) {
        padding: 6px 30px 14px;
    }
}

.dialog-footer {
    display: flex;
    justify-content: flex-end;
}

.color-409 {
    color: #409effc2;;
}

</style>

四、vue 页面中使用:

<!-- 拖拽上传 -->
<DragUpload v-if="dragUpload.visible"
            :data="dragUpload"
            @dragUploadAxiosFn="dragUploadAxiosFn"
/>
const dragUpload: any = reactive({
    visible: false,
    fileSize: 100, // 单位字节 MB
    type: { //定义上传的文件类型 = image:图片类型;video:视频类型;excel:表格类型
        image: ['png', 'jpg', 'jpeg']
    },
    deleteFileType: ['db'],
    table: [ //超出动态列表生成
        {
            title: 'SPU文件名',
            key: 'pathName0',
            minWidth: '180'
        },
        {
            title: '大小',
            key: 'size',
            minWidth: '111',
            unit: 'MB'
        }
    ]
});

 五、上传到后端接口的参数:

 六、效果图,如下:

七、额外补充,后端接收文件流的方法: 

<script setup lang="ts"> import { reactive, ref, watch } from "vue"; import { $t } from "@/locales"; import { UploadFileInfo, UploadInst } from "naive-ui"; import { exportXlsx, SheetData } from "@/utils/export-excel"; import { fetchAddInterlock, fetchUpdateInterlock, fetchUploadInterlockLedger} from "@/service/api"; defineOptions({ name: "LedgerUploadDrawer", }); interface Props { /** the type of operation */ } const props = defineProps<Props>(); const fileListLengthRef = ref(0); const uploadRef = ref<UploadInst | null>(null); interface Emits { (e: "submitted"): void; } const emit = defineEmits<Emits>(); const visible = defineModel<boolean>("visible", { default: false, }); const model: any = reactive(createDefaultModel()); function createDefaultModel(): any { return { file: null, }; } function handleInitModel() { Object.assign(model, createDefaultModel()); } function closeDrawer() { visible.value = false; } async function handleSubmit() { await validate(); // request if (props.operateType === "add" || props.operateType === "clone") { const { error } = await fetchAddInterlock(model); if (!error) { window.$message?.success($t("common.addSuccess")); } } else if (props.operateType === "edit") { const { error } = await fetchUpdateInterlock(model); if (!error) { window.$message?.success($t("common.updateSuccess")); } } closeDrawer(); emit("submitted"); } async function handleSubmit() { // request uploadRef.value?.submit(); closeDrawer(); emit("submitted"); } function handleChange(options: { fileList: UploadFileInfo[] }) { fileListLengthRef.value = options.fileList.length; } //sheet={} function downloadTemplate() { const sheets: SheetData[] = [ { sheetName: "互锁台账列表", data: [ [ "报警名称", "互锁类型", "所属模块", "模块类型", "所属产品", "触发器", "渠道号", "触发器渠道", "报警描述", "报警级别", "报警动作", "报警动作Data通道", "报警动作描述", "满足条件", "备注", ], ], }, ]; exportXlsx(sheets, "互锁台账导入模板"); } watch(visible, () => { if (visible.value) { handleInitModel(); } }); </script> <template> <NModal v-model:show="visible" preset="dialog" :title="$t(&#39;common.upload&#39;)"> <NPopover trigger="hover"> <template #trigger> <NButton @click="downloadTemplate" text type="primary">{{ $t("common.templateDownload") }}</NButton> </template> <span>点此下载导入模板</span> </NPopover> <NUpload accept=".csv,.xls,.xlsx" ref="upload" action="/api/v1/interlock/ledger/lock" :default-upload="false" :max="10" multiple @change="handleChange" > <NUploadDragger> <div style="margin-bottom: 12px"> <icon-mdi-tray-upload class="text-icon" /> </div> <NText style="font-size: 16px"> 点击或者拖动文件到该区域来上传 </NText> <NP depth="3" style="margin: 8px 0 0 0"> 请参考上传模板来提供数据,支持csv,xls,xlsx格式,单次最多上传10个文件 </NP> </NUploadDragger> </NUpload> <template #action> <NSpace :size="16"> <NButton @click="closeDrawer">{{ $t("common.cancel") }}</NButton> <NButton type="primary" @click="handleSubmit">{{ $t("common.confirm") }}</NButton> </NSpace> </template> </NModal> </template> <style scoped></style>
最新发布
07-16
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值