好的,我来帮你总结这个文件的重点笔记:

BatchUpload.vue 文件笔记

1. 功能概述

  • 文件上传组件,支持两种上传方式:
    • 点击上传
    • 拖拽上传(drag & drop)
  • 支持文件夹上传
  • 支持文件过滤(过滤隐藏文件)
  • 只支持单层文件夹结构

2. 核心实现方案

  1. 文件选择实现

    // 隐藏的文件输入框
    <input
      v-show="false"
      ref="inputFile"
      type="file"
      webkitdirectory    // 支持选择文件夹
      directory
      multiple           // 支持多选
      @change="fileChange"
    >
  2. 拖拽上传实现

    • 使用 Promise 处理异步操作
    • 核心事件:@dragover.prevent@drop.prevent
    • 文件处理流程:
      入口文件/文件夹 -> processEntries -> processEntry -> 
      (文件:readFile | 文件夹:processDirectory) -> 构建树形结构
  3. 文件夹处理的优化

    • 处理 readEntries 100个条目的限制
    • 使用 Promise.all 并行处理文件
    • 递归处理文件夹结构

3. 数据结构

// 处理后的文件结构
{
  name: string,           // 文件/文件夹名
  type: 'file' | 'folder',// 类型
  file?: File,           // 文件对象
  children?: array       // 子文件列表
}

4. 关键技术点

  1. Promise 异步处理

    • 将回调形式的 API 转换为 Promise
    • 使用 async/await 优化代码结构
    • 并行处理提升性能
  2. 文件系统 API

    • webkitGetAsEntry(): 获取文件系统入口
    • FileReader: 读取文件内容
    • DirectoryReader: 读取文件夹内容
  3. 错误处理

    • 统一的错误捕获和处理
    • 用户友好的错误提示

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>