好的,我来帮你总结这个文件的重点笔记:
BatchUpload.vue 文件笔记
1. 功能概述
- 文件上传组件,支持两种上传方式:
- 点击上传
- 拖拽上传(drag & drop)
- 支持文件夹上传
- 支持文件过滤(过滤隐藏文件)
- 只支持单层文件夹结构
2. 核心实现方案
-
文件选择实现:
// 隐藏的文件输入框 <input v-show="false" ref="inputFile" type="file" webkitdirectory // 支持选择文件夹 directory multiple // 支持多选 @change="fileChange" > -
拖拽上传实现:
- 使用 Promise 处理异步操作
- 核心事件:
@dragover.prevent和@drop.prevent - 文件处理流程:
入口文件/文件夹 -> processEntries -> processEntry -> (文件:readFile | 文件夹:processDirectory) -> 构建树形结构
-
文件夹处理的优化:
- 处理
readEntries100个条目的限制 - 使用
Promise.all并行处理文件 - 递归处理文件夹结构
- 处理
3. 数据结构
// 处理后的文件结构
{
name: string, // 文件/文件夹名
type: 'file' | 'folder',// 类型
file?: File, // 文件对象
children?: array // 子文件列表
}4. 关键技术点
-
Promise 异步处理:
- 将回调形式的 API 转换为 Promise
- 使用 async/await 优化代码结构
- 并行处理提升性能
-
文件系统 API:
webkitGetAsEntry(): 获取文件系统入口FileReader: 读取文件内容DirectoryReader: 读取文件夹内容
-
错误处理:
- 统一的错误捕获和处理
- 用户友好的错误提示
5. 性能优化
- 使用
Promise.all并行处理文件 - 避免重复遍历
- 使用 Map 和 Set 优化数据处理
- 文件夹内容分批读取
6. 注意事项
- 需要处理隐藏文件的过滤
- 文件路径需要规范化(去除开头的斜杠)
- 上传完成后需要清空 input 值
- 需要考虑浏览器兼容性(webkitdirectory 属性)
这个组件是一个很好的文件上传处理的参考实现,特别是在处理文件夹、异步操作和性能优化方面有很多值得学习的地方。
<template>
<div
class="batch-upload"
@dragover.prevent="onDragOver"
@drop.prevent="handleDrop"
>
<div class="batch-upload-con">
<img class="upload-icon" :src="imageMap.uploadIcon" alt="">
<div class="batch-upload-text">
<div>点击 <span class="highlight" @click="selectFolder">上传文件</span> or <span class="bold">拖拽文件</span> 到此处上传</div>
<div>系统将自动整理文件夹与物料</div>
</div>
</div>
<input
v-show="false"
ref="inputFile"
type="file"
webkitdirectory
directory
multiple
@change="fileChange"
>
</div>
</template>
<script setup lang="ts">
import { imageMap } from '../constant';
const emit = defineEmits<{
(e: 'update', files: BatchStorage.FolderItem[]): void;
}>();
const inputFile = ref<HTMLInputElement | null>(null);
const selectFolder = () => {
inputFile.value?.click();
};
const fileChange = (event: Event) => {
const _files = (event.target as HTMLInputElement)?.files;
if (_files) {
emit('update', buildTreeStructure(_files));
}
inputFile.value && (inputFile.value.value = ''); // 直接清除
};
function processFiles(files: any) {
if (files) {
emit('update', buildTreeStructure(files, 'filePath'));
}
}
/**
* 处理拖拽上传事件
* @param event DragEvent 拖拽事件对象
*/
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
try {
// 获取拖拽的项目
const items = event.dataTransfer?.items || [];
const entries = Array.from(items)
.map(item => item.webkitGetAsEntry())
.filter((entry): entry is any => entry !== null);
// 并行处理所有文件和文件夹
const files = await processEntries(entries);
processFiles(files);
} catch (error) {
console.error('处理拖拽项目失败:', error);
ElMessage.error('文件处理失败,请重试');
}
};
/**
* 处理文件系统入口列表
* @param entries 文件系统入口列表
* @returns Promise<File[]>
*/
async function processEntries(entries: any[]): Promise<File[]> {
const files: File[] = [];
// 并行处理所有入口
await Promise.all(
entries.map(entry => processEntry(entry, files))
);
return files;
}
/**
* 处理单个文件系统入口
* @param entry 文件系统入口
* @param files 文件数组
* @returns Promise<void>
*/
async function processEntry(entry: any, files: File[]): Promise<void> {
if (entry.isFile) {
const file = await readFile(entry);
// 规范化文件路径,移除开头的斜杠
file.filePath = entry.fullPath ? entry.fullPath.replace(/^\//, '') : file.name;
files.push(file);
} else if (entry.isDirectory) {
await processDirectory(entry, files);
}
}
/**
* 将 FileEntry 转换为 File 对象
* @param fileEntry FileEntry 对象
* @returns Promise<File>
*/
function readFile(fileEntry: any): Promise<any> {
return new Promise((resolve, reject) => {
fileEntry.file(resolve, reject);
});
}
/**
* 读取目录中的所有条目
* @param directoryReader DirectoryReader 对象
* @returns Promise<any[]>
*/
function readDirectoryEntries(directoryReader: any): Promise<any[]> {
return new Promise((resolve, reject) => {
directoryReader.readEntries(resolve, reject);
});
}
/**
* 处理目录
* @param directoryEntry DirectoryEntry 对象
* @param files 文件数组
* @returns Promise<void>
*/
async function processDirectory(directoryEntry: any, files: File[]): Promise<void> {
const reader = directoryEntry.createReader();
let entries: any[] = [];
// 读取所有条目(处理 readEntries 的 100 个条目限制)
let hasMoreEntries = true;
while (hasMoreEntries) {
const newEntries = await readDirectoryEntries(reader);
hasMoreEntries = newEntries.length > 0;
if (hasMoreEntries) {
entries = entries.concat(newEntries);
}
}
// 递归处理所有条目
await Promise.all(
entries.map(entry => processEntry(entry, files))
);
}
const onDragOver = (event: DragEvent) => {
event.dataTransfer!.dropEffect = 'copy';
};
// 构建树形结构
function buildTreeStructure(files: FileList | File[], path = 'webkitRelativePath') {
const result: any = [];
// 过滤掉隐藏文件(包括路径中的隐藏文件夹)
const validFiles = Array.from(files).filter((file: File) => {
const pathParts = (file as any)[path].split('/');
return !pathParts.some((part: string) => part.startsWith('.'));
});
// 获取原始的第一层文件夹数量
const originalFolders = new Set(
validFiles
.map((file: File) => (file as any)[path].split('/')[0])
.filter((name: string) => validFiles.some((f: File) =>
(f as any)[path].split('/').length > 1 &&
(f as any)[path].split('/')[0] === name
))
);
// 按第一层文件夹分组
const groups = new Map<string, File[]>();
validFiles.forEach((file: File) => {
const pathParts = (file as any)[path].split('/');
// 如果是根级别的文件
if (pathParts.length === 1) {
result.push({
name: pathParts[0],
type: 'file',
file
});
return;
}
// 获取第一层文件夹名
const topFolder = pathParts[0];
const isDirectFile = pathParts.length === 2; // 如果长度为2,说明是文件夹直接包含的文件
if (isDirectFile) {
if (!groups.has(topFolder)) {
groups.set(topFolder, []);
}
groups.get(topFolder)!.push(file);
}
});
// 只处理直接包含文件的文件夹
groups.forEach((files, folderName) => {
if (files.length > 0) {
result.push({
name: folderName,
type: 'folder',
childShow: true,
children: files.map(file => ({
name: file.name,
type: 'file',
file
}))
});
}
});
// 检查是否有文件夹被过滤
const validFolders = new Set(result.filter((item: any) => item.type === 'folder').map((item: any) => item.name));
if (originalFolders.size > validFolders.size) {
ElMessage.warning('已过滤不合要求的文件夹');
}
return result;
}
// 获取文件夹数量(如果之后需要用到)
const getFoldersNumber = (files: File[] | any[], path: string) => {
const set = new Set();
files.forEach(item => {
const pathParts = item[path].split('/');
if (pathParts.length > 1) {
set.add(pathParts[0]);
}
});
return set.size;
};
</script>
<style lang="scss" scoped>
.batch-upload {
box-sizing: border-box;
border: 1.4px dashed #1677FF;
border-radius: 4px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #F9FBFD;
padding: 24px 0 16px 0;
.batch-upload-con {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
.upload-icon {
width: 47px;
height: 51px;
}
.batch-upload-text {
flex-shrink: 0;
font-size: 14px;
font-weight: normal;
line-height: 22px;
color: #333333;
display: flex;
flex-direction: column;
align-items: center;
.bold {
font-weight: 500;
color: #44566C;
cursor: pointer;
}
.highlight {
cursor: pointer;
}
.select {
cursor: pointer;
color: #0040FF;
}
}
}
}
</style>