记录<el-upload>文件夹上传问题

目录

[pnpm catalog 项目如何升级 Element Plus 版本?](#pnpm catalog 项目如何升级 Element Plus 版本?)

问题:上传文件夹,多次调用后端接口

为何文件夹里有多少个文件就会调用多少次后端接口呢?

[⭐️方案 3:原生 input 劫持(彻底绕开 el-upload 多 change 机制)⭐️](#⭐️方案 3:原生 input 劫持(彻底绕开 el-upload 多 change 机制)⭐️)


背景:

有一个需求功能需要上传一整个文件夹,一开始 AI 给出的是用原生方法实现的,但后来发现 Element Plus 的 <el-upload> 也可以实现文件夹上传,所以调整改成使用组件来实现。


问题:

Element Plus 的 el-upload 如何实现文件夹上传


一、最简方案(官方支持)

Element Plus 2.13.1+ 内置 directory 属性,直接 开启 文件夹上传。

html 复制代码
<template>
  <el-upload
    drag
    directory      <!-- 核心:开启文件夹选择 -->
    multiple
    action="/api/upload"
    :on-change="handleChange"
    :http-request="customUpload"
  >
    <el-icon><upload-filled /></el-icon>
    <div>拖放文件夹或点击选择</div>
  </el-upload>
</template>

<script setup>
import { UploadFilled } from '@element-plus/icons-vue'

// 可选:自定义上传(保留目录结构)
const customUpload = async (options) => {
  const formData = new FormData()
  const file = options.file
  // 关键:获取文件相对路径(含目录)
  const relativePath = file.webkitRelativePath 
  formData.append('file', file)
  formData.append('path', relativePath) // 传给后端重建目录
  // 调用axios上传...
}
</script>

二、关键细节

  • directory :开启后只能选 文件夹 ,内部文件会 扁平化列出。
  • webkitRelativePath :每个文件自带相对路径(如 文件夹/子文件夹/file.txt),用于后端还原目录结构。
  • 浏览器支持:Chrome、Edge、Safari 支持;Firefox 部分版本支持。

三、低版本兼容(<2.13.1)

手动设置 webkitdirectory 属性:

html 复制代码
<template>
  <el-upload ref="uploadRef" ...>...</el-upload>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue'
const uploadRef = ref(null)

onMounted(() => {
  nextTick(() => {
    const input = uploadRef.value?.$el.querySelector('input[type="file"]')
    if (input) input.webkitdirectory = true // 强制开启文件夹选择
  })
})
</script>

四、后端配合(Node.js / Express)

接收 path 字段,按路径创建目录并保存文件:

javascript 复制代码
app.post('/api/upload', async (req, res) => {
  const { path } = req.body
  const dir = path.dirname(path)
  await fs.promises.mkdir(dir, { recursive: true }) // 递归创建目录
  // 保存文件...
})

五、常见问题

  • 只能选文件不能选文件夹 :检查 directory 属性是否存在、Element Plus 版本是否≥2.13.1
  • 丢失目录结构 :前端必须传递 webkitRelativePath,后端按路径重建目录。
  • Firefox 不支持:属于浏览器限制,可提示用户使用 Chrome / Edge。

项目内的版本信息:【 项目里下载的 element-plus 版本低 】

catalog: element-plus: ^2.9.9(版本号太低了,不支持 文件夹上传功能)

当前版本 Element Plus ^2.9.9 ,不满足文件夹上传要求(需要 ≥2.13.1),才可以直接用 directory 属性


这张图是 Element Plus 官方文档 里关于 el-upload 组件的「文件夹上传」说明,我帮你拆解一下关键信息,顺便结合你当前的 2.9.9 版本给出适配方案。

🔍 文档核心信息解读

1、directory 属性的版本标记

文档里明确标注了这个属性是在 2.13.1 版本新增的,作用是「启用文件夹上传」。

  • 开启后,用户只能选择文件夹,无法单独选文件。
  • 选中文件夹后,里面的文件会被 "扁平化" 处理(也就是文件列表里只会看到 单个文件,不会显示目录层级)。
  • 但每个文件对象会自带 webkitRelativePath 属性,记录它在文件夹里的完整路径(比如 图片/风景/sea.jpg),后端可以用这个字段还原目录结构。

2、其他相关属性说明

属性 说明
auto-upload 是否自动上传文件
http-request 自定义上传请求方法(你可以在这里处理目录路径)
disabled 是否禁用上传
limit 允许上传的文件数量上限

⚠️ 关键问题:你的 2.9.9 版本怎么办?

  • 你当前的 2.9.9 版本 早于 2.13.1 ,所以官方文档里的 directory 属性在这个版本里 不存在,直接写会不生效。
  • 但你可以通过手动给上传组件的原生 <input type="file"> 添加 webkitdirectory 属性,来实现同样的效果。

适配 2.9.9 的完整代码

html 复制代码
<template>
  <el-upload
    ref="uploadRef"
    multiple
    drag
    action=""
    :http-request="handleCustomUpload"
  >
    <el-icon size="40"><UploadFilled /></el-icon>
    <p class="el-upload__text">点击或拖拽文件夹上传</p>
  </el-upload>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import axios from 'axios'

const uploadRef = ref(null)

// 关键:手动开启文件夹选择
onMounted(() => {
  nextTick(() => {
    const inputEl = uploadRef.value?.$el.querySelector('input[type="file"]')
    if (inputEl) {
      inputEl.webkitdirectory = true
      inputEl.directory = true // 兼容写法
    }
  })
})

// 自定义上传,保留目录路径
const handleCustomUpload = async (options) => {
  const { file, onSuccess, onError } = options
  const formData = new FormData()
  formData.append('file', file)
  // 用 webkitRelativePath 记录文件在文件夹中的路径
  formData.append('relativePath', file.webkitRelativePath || '')

  try {
    const res = await axios.post('/api/upload-folder', formData, {
      headers: { 'Content-Type': 'multipart/form-data' }
    })
    onSuccess(res)
  } catch (err) {
    onError(err)
  }
}
</script>

✅ 两个方案的对比

方案 版本要求 优点 缺点
直接用 directory 属性 ≥2.13.1 写法简单,官方原生支持 你的 2.9.9 版本不兼容
手动设置 webkitdirectory 所有版本 低版本也能实现,效果一致 需要操作 DOM,组件更新时可能失效

💡 建议

  • 如果你能升级 Element Plus 到 2.13.1+,直接用 directory 属性最省心。
  • 如果暂时不能升级,就用上面的手动方案,注意测试一下在不同浏览器(Chrome / Edge 都支持,Firefox 部分版本兼容)下的表现。

问题:

pnpm catalog 项目 如何升级 Element Plus 版本

这里给你一套 pnpm catalog 项目升级 Element Plus 到 2.13.1+ 的完整步骤,全程无坑可直接复制执行。


一、修改 catalog 版本(核心步骤)

打开项目根目录的 pnpm-workspace.yaml ,修改 element-plus 的版本号:

bash 复制代码
catalog:
  # 原来的写法:element-plus: ^2.9.9
  element-plus: ^2.13.1

二、安装 / 更新依赖

在项目根目录执行命令,让 pnpm 同步更新所有子包的依赖:

bash 复制代码
pnpm install

执行后,所有依赖 element-plus: catalog: 的子包,都会自动同步为 catalog 里的新版本。


三、验证升级结果

1. 查看实际安装版本

bash 复制代码
pnpm list element-plus --depth=0

输出结果里,版本号会显示为 2.13.x更高(2.14.1),说明升级成功。

2. 代码里运行时验证(可选)

在项目里任意组件中添加:

javascript 复制代码
import { version } from 'element-plus'
console.log('当前 Element Plus 版本:', version)

运行项目,控制台打印的版本号为 2.13.1+ 即正常。


四、升级后的关键适配(避免踩坑)

1. directory 属性直接使用

升级后,你可以直接用官方原生的 directory 属性,无需再手动操作 DOM:

html 复制代码
<el-upload
  directory
  multiple
  action="/api/upload"
>
  点击选择文件夹上传
</el-upload>

2. 检查可能的兼容性问题

  • 确认项目中是否有自定义覆盖 el-upload 样式 / 事件的代码,升级后部分内部结构可能变化,需验证上传组件是否正常。
  • 若项目使用了 Element Plus 的其他组件(如表单、弹窗),需运行项目整体功能测试,排查是否有 API 变更导致的报错。

五、如果不想升级,如何在 2.9.9 中临时实现?

如果你暂时不想升级版本,也可以用之前提到的「手动设置 webkitdirectory」方案,无需修改依赖版本:

html 复制代码
<template>
  <el-upload ref="uploadRef" multiple action="">
    点击选择文件夹上传
  </el-upload>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue'
const uploadRef = ref(null)

onMounted(() => {
  nextTick(() => {
    const input = uploadRef.value?.$el.querySelector('input[type="file"]')
    if (input) {
      input.webkitdirectory = true
      input.directory = true
    }
  })
})
</script>

六、回滚方案(如果升级出问题)

如果升级后出现兼容性报错,可直接回滚:

  1. 改回 pnpm-workspace.yaml 里的版本号:element-plus: ^2.9.9
  2. 重新执行:pnpm install

问题:

项目是用的 2.9.9 版本,那我升级后的 2.13.1 或更高版本应该对项目没啥影响吧?

【 升级完测试了下 没啥影响


结论先说:大概率没事,但不是 100% 没影响 。2.9.9 → 2.13.1 是 小版本升级(2.x 内) ,官方保持 向下兼容 ,但有几个 隐性风险点要注意。

一、正常情况下:基本无影响

从 2.9.9 到 2.13.1,属于 ** 同主版本(2.x)** 的小幅迭代:

  • 没有破坏性 API 变更(官方没删属性、没改事件名)
  • 组件行为基本一致,只是多了新功能、修了 bug
  • 你关心的 el-upload :2.13.1 才正式加了 directory 属性,你原来 2.9.9 的代码不会被影响

所以:你现在所有页面、组件,升级后应该能正常跑

二、但要注意这 3 个 "坑"(2.10+ 引入)

1)el-submenu 改名(2.10.0+)

  • 旧:<el-submenu>
  • 新:<el-sub-menu>(必须带连字符)
  • 你项目里如果写了 el-submenu,升级后会 找不到组件、报错

2)自定义样式可能轻微错位

  • 新版本微调了一些组件 默认样式、z-index、类名
  • 如果你写了 深度覆盖样式 (比如 ::v-deep 改了按钮、弹窗、菜单),可能有 轻微错位 / 覆盖失效

3)按需引入 / 自动导入配置

  • unplugin-vue-componentsunplugin-element-plus 的项目: 升级后 最好重新跑一次 pnpm i,避免样式 / 组件导入失败。

三、给你最稳妥的升级步骤(安全不翻车)

  1. 改 catalog:

    html 复制代码
    catalog:
      element-plus: ^2.13.1
  2. 安装:

    javascript 复制代码
    pnpm install
  3. 本地跑一遍全功能测试 (重点看):

    • 菜单 / 侧边栏(有没有 el-submenu
    • 弹窗、抽屉、下拉(层级、样式)
    • 表单、表格、上传(功能正常)
  4. 没问题再上测试 / 生产。

四、一句话总结

2.9.9 → 2.13.1:95% 场景无感升级,5% 要检查 submenu 和自定义样式

你只要 不用旧 el-submenu、没写大量深度覆盖样式,基本可以放心升。


问题:

catalog: element-plus: ^2.13.1 我这样改写的,为啥下载更新的版本是 2.14.2


先说结论:不是你写错了,是 ^ 符号的正常规则 ,并且现在 registry 上已经有 2.14.2(比 2.13.1 新)。

1. ^2.13.1 到底是什么意思?

在语义化版本(SemVer)里:

  • ^2.13.1 = >= 2.13.1 且 < 3.0.0
  • 也就是:所有 2.x.x 的新版本都可以装 ,pnpm 会默认装 这个范围内最新的稳定版

你写的:

bash 复制代码
catalog:
  element-plus: ^2.13.1

意思是:只要是 2.13.1 以上、不到 3.0.0 的版本都可以 ,所以 pnpm 直接给你装了当前最新的 2.14.2

2. 为什么会跳到 2.14.2?

  • Element Plus 最近刚发了 2.14.0 / 2.14.1 / 2.14.2(6 月 12 日左右)
  • 这些都属于 2.x 主版本 ,满足 ^2.13.1 的范围
  • pnpm install / pnpm update 会 自动拉这个区间里最新版

3. 如果你就要「锁定在 2.13.1」

^ 去掉,写 精确版本

bash 复制代码
catalog:
  element-plus: 2.13.1

然后执行:

bash 复制代码
pnpm install

这样就会 固定死 2.13.1,不会升到 2.14.x

4. 2.13.1 → 2.14.2 要不要紧?

  • 都是 2.x 小版本 :官方承诺 向下兼容
  • 主要是 修 bug、加小功能,没有破坏性改动
  • 你之前关心的 el-upload directory2.13.1 就有,2.14.2 也保留

所以:直接用 2.14.2 没问题;如果要严格和 2.13.1 一致,就去掉 ^ 锁死版本


问题:

后端 api/GongshiAnalyz 上传接口入参:

后端说:"MultipartFile 格式就行,List<MultipartFile>,files 接口的入参格式"


结合你 el-upload 文件夹上传 + 后端接收 List<MultipartFile> files 的需求,分两种写法(原生自动上传 / 自定义上传),适配 Element Plus 2.13+ 版本,直接复制可用。

后端要点:

  1. 接口接收 List<MultipartFile>参数名 固定为 files
  2. 请求类型:multipart/form-data
  3. 前端只需循环追加文件到 FormData,字段名统一为 files

一、方式 1:使用 el-upload 原生自动上传(最简推荐)

开启 directory 文件夹上传,组件自动把所有文件以 files 数组提交 ,完美适配后端 List<MultipartFile>。【 实际上并不会,会调用多次接口,每次接口只上传一个文件 】

html 复制代码
<template>
  <!-- 
    directory: 开启文件夹上传
    multiple: 多选文件/文件夹内多文件
    action: 你的后端接口地址 /api/GongshiAnalyz
    name: 固定为 files,和后端入参名一致(核心!)
  -->
  <el-upload
    directory
    multiple
    drag
    action="/api/GongshiAnalyz"
    name="files"
    :on-success="onUploadSuccess"
    :on-error="onUploadError"
  >
    <el-icon size="40"><UploadFilled /></el-icon>
    <p>点击/拖拽文件夹上传</p>
  </el-upload>
</template>

<script setup>
import { UploadFilled } from '@element-plus/icons-vue'

const onUploadSuccess = (res) => {
  console.log('上传成功', res)
}
const onUploadError = (err) => {
  console.error('上传失败', err)
}
</script>

关键说明

1、name="files"

这是最关键的配置,el-upload 会把所有文件以 files 作为字段名提交,

后端直接用 List<MultipartFile> files 接收。

【 实际是多次调用接口,一个接口只上传一个文件,每个接口里的文件名是 files 】

2、开启 directory 后,选择文件夹,内部所有文件会被批量提交,天然对应后端集合参数。

3、请求头、Content-Type 组件自动处理,不用手动配置。


二、方式 2:自定义上传 http-request(需要额外传其他参数时用)

如果接口还要附带普通文本参数、需要自定义请求逻辑,用自定义上传,手动组装 FormData

html 复制代码
<template>
  <el-upload
    directory
    multiple
    drag
    action=""
    name="files"
    :http-request="customUpload"
  >
    <el-icon size="40"><UploadFilled /></el-icon>
    <p>点击/拖拽文件夹上传</p>
  </el-upload>
</template>

<script setup>
import { UploadFilled } from '@element-plus/icons-vue'
import axios from 'axios'

// el-upload 文件夹上传会逐个触发该方法,遍历所有文件
const customUpload = async (options) => {
  const { file, fileList, onSuccess, onError } = options
  const formData = new FormData()

  // 遍历全部文件,统一字段名 files,适配 List<MultipartFile>
  fileList.forEach(item => {
    formData.append('files', item.raw)
    // 如需传递文件夹路径:formData.append('path', item.raw.webkitRelativePath)
  })

  try {
    const res = await axios.post('/api/GongshiAnalyz', formData)
    onSuccess(res)
  } catch (err) {
    onError(err)
  }
}
</script>

三、后端接口参考(Java Spring Boot)

和你描述的入参对应,接口写法示例:

java 复制代码
@PostMapping("/api/GongshiAnalyz")
public R upload(@RequestParam("files") List<MultipartFile> files) {
    // 业务逻辑处理 files 集合
    return R.ok();
}

四、常见问题排查

1、后端接收不到文件 / 集合为空

  • 检查前端 name="files" 是否和后端 @RequestParam("files") 完全一致(大小写敏感)
  • 确认请求是 POST + multipart/form-data

2、只能传单个文件,集合只有一条数据

必须保留 multiple 属性,文件夹上传依赖该属性批量提交

3、需要保留原文件夹路径

FormData 额外追加 webkitRelativePath 字段,后端根据路径重建目录即可。


接口 Payload 下的 Form Data(31),展开显示 0:(binary)

所以我这传参格式是对的吗?


结论:格式是正常的,二进制 (binary) 代表文件流本身,传参结构没问题

一、简单解读

Form Data 里显示 0: (binary)1: (binary) ... 是浏览器 正常展示文件的表现:

  • 普通文本会直接显示内容
  • 文件 / 二进制流 统一展示为 (binary),不代表传参错误
  • 你这里总数 31,说明成功把文件夹里 31 个文件 都以 files[] 数组形式提交了,完全匹配后端 List<MultipartFile> files

二、快速确认两点(彻底放心)

1、字段名必须是 files

看 FormData 列表里,每一项的 key 是不是 files (数组形式 files[0] / files[1] 也完全兼容 Java List<MultipartFile>)。

✅ 符合:后端能正常接收集合

❌ 如果 key 不是 files,改组件 name="files"

2、请求头 Content-Type

网络面板 → 请求头,确认是:

bash 复制代码
Content-Type: multipart/form-data; boundary=xxx

el-upload 自动生成,不用手动改,出现这个就没问题。


三、如果后端还是接收不到数据(排查方案)

情况 1:使用原生自动上传(推荐)

保证配置这三个核心属性即可:

html 复制代码
<el-upload
  directory
  multiple
  name="files"  <!-- 字段名和后端严格一致 -->
  action="/api/GongshiAnalyz"
>

情况 2:用了 http-request 自定义上传

必须遍历 fileList 循环 append,不要只 append 当前单个 file

javascript 复制代码
const customUpload = async (options) => {
  const { fileList, onSuccess, onError } = options
  const formData = new FormData()
  // 重点:遍历整个文件列表
  fileList.forEach(item => {
    formData.append('files', item.raw)
  })
  const res = await axios.post('/api/GongshiAnalyz', formData)
  onSuccess(res)
}

四、补充

  1. 文件夹上传产生多条 binary 条目是 正常现象,不用纠结这个展示样式。
  2. 只要数量和你选择的文件数量一致,字段名是 files,前端传参就完全符合后端 List<MultipartFile> 要求。

问题:

<el-upload> 组件能否通过自身方法设置好 FormData 的键名 key 为 files ?【 不能 】

我的需求功能是直接上传一个文件夹,里面有多个文件夹这种。


一、核心答案

1. el-upload 原生自带 name 属性,直接修改 FormData 文件键名

文档里绿框标注的 name 属性就是用来定义文件表单 key 的:

  • 默认值:file
  • 你只要写成 name="files",浏览器 FormData 里所有文件的 key 都会统一为 files,完美匹配后端 List<MultipartFile> files,不需要手动操作 FormData。【 实际只是会调用多次接口,每个接口里的单文件 key 会为 files 】

2. 多层文件夹嵌套上传完全支持

搭配 directory + multiple 两个属性:

  • directory:开启文件夹选择,支持多层子目录读取
  • multiple:允许多文件,自动把所有子目录文件全部收集
  • 每个文件对象自带 webkitRelativePath 属性,包含完整层级路径(如 a/b/c/d.png),可通过 data 属性附加到请求传给后端用于还原目录。

完整原生自动上传代码(无需自定义 http-request)

html 复制代码
<template>
  <el-upload
    directory
    multiple
    name="files"
    action="/api/GongshiAnalyz"
    :data="extraParams"
    :on-success="handleSuccess"
    :on-error="handleError"
  >
    <el-icon><UploadFilled /></el-icon>
    <p>拖拽/点击上传多层文件夹</p>
  </el-upload>
</template>

<script setup>
import { UploadFilled } from '@element-plus/icons-vue'

// 额外业务参数(可选)
const extraParams = {
  type: 'folderAnalyze'
}

const handleSuccess = (res) => {
  console.log('上传完成', res)
}
const handleError = (err) => {
  console.error('上传失败', err)
}
</script>

二、关键原理说明

1、name="files" 如何生效

组件底层原生 <input type="file">name 会被赋值为你设置的值,多选文件时 FormData 自动生成多条 files 键,Java SpringBoot 可直接用 List<MultipartFile> files 接收,无需额外处理数组下标。

2、多层文件夹目录结构怎么传给后端

原生自动上传模式无法直接批量携带每个文件的 webkitRelativePath,两种解决方案:

方案 A:自定义上传 http-request(推荐,能携带路径)

html 复制代码
<el-upload
  directory
  multiple
  name="files"
  action=""
  :http-request="customUpload"
>
  <el-icon><UploadFilled /></el-icon>
</el-upload>

<script setup>
import axios from 'axios'
const customUpload = async (opts) => {
  const { fileList, onSuccess, onError } = opts
  const formData = new FormData()
  // 批量追加文件,key统一为 files
  fileList.forEach(item => {
    formData.append('files', item.raw)
    // 传递当前文件完整目录路径
    formData.append('filePathList', item.raw.webkitRelativePath)
  })
  try {
    const res = await axios.post('/api/GongshiAnalyz', formData)
    onSuccess(res)
  } catch (e) {
    onError(e)
  }
}
</script>

方案 B:后端单独解析请求头部 / 文件对象

仅上传文件,后端读取文件原始路径信息(部分框架支持)。


三、避坑要点

  1. 必须同时写 directory + multiple,多层文件夹才能全部读取;只写 directory 不写 multiple,只会读取单个文件。
  2. name="files" 大小写严格匹配后端 @RequestParam("files"),不能写成 Files/file
  3. 升级到 2.13.1+ 后 directory 属性为官方原生,兼容性优于手动操作 DOM。
  4. 浏览器限制:Chrome/Edge 完整支持多层文件夹;Firefox 对深层目录读取存在兼容问题。

四、接口传参校验标准

打开浏览器网络面板查看 Form Data:

html 复制代码
files: (binary) // 文件1
files: (binary) // 文件2
files: (binary) // 文件3
filePathList: 一级目录/子目录/xxx.png
filePathList: 一级目录/yyy.jpg

这种格式后端 List<MultipartFile> files 可直接接收,格式完全正确。


问题:上传文件夹,多次调用后端接口

html 复制代码
<el-upload 
  ref="folderUploadRef" 
  class="folder-upload"
  action="/api/v1/api/GongshiAnalyz"
  name="files"
  :show-file-list="false"
  directory
  multiple
  :disabled="tableLoading">
  <el-button type="primary" :icon="Upload" :loading="tableLoading">
    上传文件夹
  </el-button>
</el-upload>

这样上传一个文件夹后,文件夹里面有几个文件就会调用几次后端接口,组件本身没有属性或方法可以将文件夹内的文件归并到一起再请求吗? 还是说只能通过手动创建 FormData 对象,然后 append 循环追加好文件后再传参调接口呢?


核心结论

1、原生自动上传(你当前写法):文件是 逐个 分片请求,有多少文件就发多少次接口

el-upload 默认行为是 单个文件 单独 发起 一次请求 ,哪怕开了 directory / multiple,内部文件也会 循环 逐个 上传,没有原生属性 / 开关能一键合并所有文件为单次请求。

2、想要一次性把文件夹所有文件合并成一次请求,唯一方案:使用 http-request 自定义上传,手动组装 FormData,没有其他捷径。


一、为什么你现在会多次请求?

组件原生逻辑:

  • 选择文件夹后,内部每一个文件都会触发 on-change,并逐个调用 action 地址;
  • 底层是循环调用上传逻辑,每个文件独立请求,无法通过属性关闭这个行为;
  • name="files" 只能控制单个请求内的字段名,不能改变 "一文件一请求" 的机制。

二、合并所有文件单次请求完整代码(直接替换你的组件)

html 复制代码
<template>
  <el-upload
    ref="folderUploadRef"
    class="folder-upload"
    action=""
    name="files"
    :show-file-list="false"
    directory
    multiple
    :disabled="tableLoading"
    :http-request="handleFolderUpload"
  >
    <el-button type="primary" :icon="Upload" :loading="tableLoading">
      上传文件夹
    </el-button>
  </el-upload>
</template>

<script setup>
import { ref } from 'vue'
import { Upload } from '@element-plus/icons-vue'
import axios from 'axios'

const tableLoading = ref(false)
const folderUploadRef = ref(null)

// 自定义合并上传:所有文件统一一次请求
const handleFolderUpload = async (options) => {
  const { fileList, onSuccess, onError } = options
  // 无文件直接返回
  if (!fileList.length) return
  tableLoading.value = true

  const formData = new FormData()
  // 循环所有文件夹内文件,统一key=files
  fileList.forEach(fileItem => {
    formData.append('files', fileItem.raw)
    // 可选:传递文件原始层级路径,后端还原文件夹结构
    formData.append('path', fileItem.raw.webkitRelativePath)
  })

  try {
    const res = await axios.post('/api/v1/api/GongshiAnalyz', formData)
    onSuccess(res)
  } catch (err) {
    onError(err)
  } finally {
    tableLoading.value = false
    // 上传完成清空文件列表,避免重复上传
    folderUploadRef.value.clearFiles()
  }
}
</script>

关键改动说明

  1. action="" 置空,禁用原生自动分批上传;
  2. 通过 http-request 接管全部上传逻辑,拿到完整 fileList
  3. 循环 fileList 批量追加进同一个 FormData,只发起 1 次接口请求
  4. 保留 name="files" 不影响,自定义上传中我们手动控制 key 即可。

三、补充两个关键细节

1. 有没有官方原生属性能实现合并上传?

查阅 Element Plus el-upload 全量 API:

  • 不存在 merge / batch / single-request 这类合并批量上传属性;
  • 官方文档中,多文件一次性提交的标准实现方案只有 http-request 手动组装 FormData。

2. 两种上传模式对比

模式 请求次数 适用场景
原生 action 自动上传(你现在写法) 1 个文件 = 1 次请求 大文件分片、需要单独进度、单独处理每个文件结果
http-request 自定义 FormData 整个文件夹仅 1 次请求 后端接收 List<MultipartFile>,要求一次接收全部文件(你的业务场景)

3. 后端适配说明

最终请求的 FormData 会是多条 files 二进制字段,SpringBoot @RequestParam List<MultipartFile> files 可直接接收,完全符合后端要求。


四、可选优化:限制重复提交

如果用户重复点击按钮,可加锁控制,避免并发多次请求:

javascript 复制代码
const handleFolderUpload = async (options) => {
  if (tableLoading.value) return // 正在上传直接拦截
  // ...剩余上传逻辑
}

1. 截图文档关键信息解读

这是 Element Plus el-upload 官方属性文档:

  • action 标记 required(必填):原生自动上传模式必须填写接口地址;
  • 如果你使用 http-request 自定义上传 ,可以直接把 action="" 置空,规避原生分批上传逻辑,文档里没有禁止空字符串;
  • 仅靠页面可见的 actionheaders 等原生属性,没有任何配置项能实现 "文件夹所有文件合并单次请求"

2. 回到你的核心疑问

问题 1:组件原生属性能否把文件夹所有文件合并成一次请求?

不能

文档里所有公开属性(multiple/directory/name/action)仅控制「允许多选 / 选文件夹、文件字段名、请求地址」,底层逻辑固定:每个文件单独触发一次上传请求,不存在批量合并提交的开关 / 属性。

问题 2:唯一可行方案

只能通过 :http-request 自定义上传钩子,手动读取完整 fileList,循环把全部文件追加到同一个 FormData,只发起一次接口请求,没有其他替代方式。

3. 适配你现有代码的完整修改示例

html 复制代码
<template>
  <el-upload
    ref="folderUploadRef"
    class="folder-upload"
    action=""  <!-- 置空,关闭原生分批上传 -->
    name="files"
    :show-file-list="false"
    directory
    multiple
    :disabled="tableLoading"
    :http-request="batchUploadFolder"  <!-- 接管上传逻辑 -->
  >
    <el-button type="primary" :icon="Upload" :loading="tableLoading">
      上传文件夹
    </el-button>
  </el-upload>
</template>

<script setup>
import { ref } from 'vue'
import { Upload } from '@element-plus/icons-vue'
import axios from 'axios'

const tableLoading = ref(false)
const folderUploadRef = ref(null)

// 合并所有文件一次性上传
const batchUploadFolder = async (options) => {
  const { fileList, onSuccess, onError } = options
  if (!fileList.length || tableLoading.value) return

  tableLoading.value = true
  const formData = new FormData()
  // 循环全部文件,统一key为files,匹配后端 List<MultipartFile>
  fileList.forEach(item => {
    formData.append('files', item.raw)
    // 可选:传递文件完整层级路径,后端还原目录结构
    formData.append('filePath', item.raw.webkitRelativePath)
  })

  try {
    const res = await axios.post('/api/v1/api/GongshiAnalyz', formData)
    onSuccess(res)
  } catch (err) {
    onError(err)
  } finally {
    tableLoading.value = false
    folderUploadRef.value.clearFiles() // 上传后清空列表,防止重复提交
  }
}
</script>

4. 两种模式对比总结

上传模式 请求次数 能否通过属性控制合并 适用场景
原生 action 自动上传 1 个文件 = 1 次请求 不支持,无对应属性 单文件独立处理、分片上传
http-request 自定义上传 整个文件夹仅 1 次请求 手动组装 FormData 实现 后端要求一次性接收全部文件集合(你的业务场景)

5. 补充避坑点

  1. action="" 置空不会报错:http-request 优先级高于 action,会完全接管请求流程,规避文档里 action required 的限制;
  2. name="files" 在自定义上传中仅做标识,实际 FormData 的 key 由 formData.append('files', xxx) 控制,保持一致即可;
  3. 多层嵌套文件夹无需额外配置,directory 属性会自动读取所有子目录内文件,全部存入 fileList

问题:

写 action="#",有啥区别?

一、action="#"action="" 核心区别

1. action="#"(文档默认占位写法)

  • 1、不会禁用原生上传逻辑

# 是合法字符串地址,组件仍会执行原生自动上传:每一个文件单独发起 POST 请求,请求地址是 #(当前页面锚点),会报 404。

  • 2、底层逻辑不变

只要 action 有非空字符串,就会走「单文件单次请求」的原生逻辑,你选文件夹有多少文件就发多少次请求。

  • 3、用途

仅作文档占位、临时填充必填项,不能用来批量合并文件上传

2. action=""(空字符串,自定义上传专用)

  • 关闭原生自动上传流程

当同时配置 :http-request 时,空字符串会让组件放弃内置 XHR 请求,完全把上传控制权交给你的自定义函数,不会自动发起任何网络请求。

  • 不会出现多次请求

所有文件统一在 http-request 内手动组装 FormData,一次调用后端接口。

  • 不会触发无效的 # 锚点请求,无多余 404 报错。

二、分场景演示差异

场景 A:你写 action="#",不写 http-request

html 复制代码
<el-upload
  action="#"
  name="files"
  directory
  multiple
>
  • 行为:选中文件夹内 31 个文件 → 自动发起 31 次 POST 请求,地址为 域名/#,全部 404;
  • 弊端:大量无效请求,不符合你「一次传全部文件」需求。

场景 B:你写 action="#",同时配 http-request

html 复制代码
<el-upload
  action="#"
  name="files"
  directory
  multiple
  :http-request="customUpload"
>
  • 行为:http-request 优先级更高,不会请求 #,功能上能正常批量上传;
  • 隐患:代码表意混乱,阅读者会误以为还存在原生地址请求,不规范。

场景 C:标准规范写法(推荐)

html 复制代码
<el-upload
  action=""
  name="files"
  directory
  multiple
  :http-request="customUpload"
>
  • 行为:原生上传直接关闭,所有请求逻辑由你自定义函数接管;
  • 优势:语义清晰,一眼就能看出是自定义上传,无无效地址,无多余报错。

三、总结一句话

  1. action="#":只是填充必填占位符,无法关闭原生分批上传 ,哪怕配了 http-request 也只是不报错,写法不规范;
  2. action="":专门搭配 http-request 使用,彻底禁用组件自带的自动上传逻辑,是批量合并文件上传的标准写法;
  3. 你的业务需要一次性提交文件夹全部文件,必须用 action="",不要写 #

问题:

直接不写 action 这个属性不也行吗?

为啥我现在删了也没报错,功能也正常【 其实不写也没事 】

为什么删掉 action 也不报错、功能正常?分两层原因讲清楚

一、核心根源:http-request 完全接管上传逻辑,绕过了 action 校验

1、组件内部执行优先级

只要你绑定了 :http-request,组件会直接 跳过内置原生上传逻辑 ,不会进入读取 action 地址发请求的分支,自然不会触发「action 必填」的校验报错。

  • http-request、仅靠 action 自动上传:此时源码会强制校验 action 是否存在,删掉就控制台抛错、上传失效;
  • http-request 自定义上传:无论写不写 action、写 "" / #,组件都不会校验 action,全部走你自己的 axios 请求。

2、版本放宽了 Prop 校验

你当前版本是 2.14.2,新版 Element Plus 弱化了 action 的运行时强校验:

文档标注 required 只是 接口规范说明,仅在「原生自动上传模式」下生效;自定义上传场景允许省略,不会抛出运行时异常。旧版 2.9.x 早期版本会警告,新版本已优化。

二、三种写法的底层差异(你现在省略 action 属于第三种)

1. <el-upload action="/api/xxx" ...>(原生自动上传)

  • 必须保留 action,删掉直接报错;
  • 每个文件单独发起一次请求,不符合你批量上传文件夹需求。

2. <el-upload action="" :http-request="xxx" ...>(规范推荐写法)

  • 显式告知阅读者:禁用原生上传、全部自定义;
  • 规避后续维护者误以为是自动上传接口,语义清晰。

3. <el-upload :http-request="xxx" ...>(完全省略 action,你现在的写法)

  • 运行无报错、功能完全正常;
  • 隐患:可读性差,其他人看代码无法一眼区分是自动上传还是自定义上传;后续如果误删 http-request,会立刻出现上传失效 + 控制台报错。

三、给你的实操建议

  1. 本地开发、临时调试 :省略 action 完全没问题,不影响功能;
  2. 上线 / 团队协作代码 :建议补 action="",统一规范,降低维护踩坑概率;
  3. 不推荐写 action="#"# 是合法路由地址,一旦后续删掉 http-request,会疯狂发起无效 404 请求。

补充总结

文档标 action required 是 ** 针对「原生自动上传场景」** 的约束;

只要你使用 http-request 接管上传,组件会跳过该校验,删不删 action 都能正常运行,只是代码规范层面有优劣之分。


问题:

TypeScript 复制代码
const inputEl = folderUploadRef.value?.$el.querySelector("input") as HTMLInputElement;
const rawFiles = inputEl?.files ? Array.from(inputEl.files) : [];

inputEl 元素能获取到,但 inputEl.files 的 FileList 的 length:0,

并且 rawFiles 是空数组。为什么?


问题原因 + 完整解决方案

一、核心 3 个最常见原因(按概率排序)

1. 触发上传后没有真正选择文件(最常见)

1、手动点击弹窗直接点取消、点空白关闭文件选择框

2、拖拽文件中途松开、拖拽不支持的文件类型被浏览器过滤

此时 input.files 天然是空 FileList,length=0

2. input 被重复重置 /value 被清空

el-upload 内部逻辑:每次打开文件选择框、上传完成后会自动清空 input.value

流程:

  1. 点击上传 → 打开文件弹窗
  2. 选择文件确认
  3. el-upload 内部立刻执行 input.value = '' 清空文件列表
  4. 你同步读取 inputEl.files 时已经被清空,拿到空数组

3. 代码执行时机超前(异步时序问题)

场景 1:绑定 click 事件直接读取 files

TypeScript 复制代码
// 错误写法
const handleClick = () => {
  inputEl.click() // 异步唤起文件弹窗
  const files = inputEl.files // 弹窗还没选文件,直接读取为空
}

场景 2:监听 el-upload 的 click 钩子,而不是 change 钩子

文件选择完成只会触发 input change,click 时还没选文件。

二、次要原因

  1. input 加了 multiple=false 单选,重复选同名文件浏览器不会触发 change,files 为空
  2. 文件类型 accept 限制严格,选中文件后缀不匹配,浏览器丢弃文件
  3. 组件 v-if 控制,获取元素后组件销毁重建,input 失效

标准正确写法(Vue3 + Element Plus el-upload)

方案 1:监听 input change 事件(推荐,100% 能拿到文件)

TypeScript 复制代码
const folderUploadRef = ref<InstanceType<ElUpload>>()

const triggerUpload = () => {
  const inputEl = folderUploadRef.value?.$el.querySelector('input') as HTMLInputElement
  if (!inputEl) return

  // 先绑定一次change回调,选完文件立即读取
  inputEl.onchange = function () {
    // 这里才是文件选择完成的时机,不会被提前清空
    const fileList = this.files
    const rawFiles = Array.from(fileList || [])
    console.log('选中文件', rawFiles)
  }
  inputEl.click()
}

方案 2:使用 el-upload 原生钩子(最优,不用手动操作 DOM)

el-upload 自带 on-change,组件内部已经缓存文件,不会被清空,完全不需要手动拿 input:

html 复制代码
<template>
  <el-upload ref="folderUploadRef" @change="handleFileChange">
    <button>上传文件夹</button>
  </el-upload>
</template>
<script setup lang="ts">
const handleFileChange = (file: UploadFile, fileList: UploadFile[]) => {
  // fileList 直接拿到所有选中文件,无需操作原生input
  console.log('文件列表', fileList)
  const rawFiles = fileList.map(item => item.raw as File)
}
</script>

三、如果你必须手动读取 input,修复时序 bug

错误逻辑:先 click 再读 files

正确逻辑:等待 onchange 回调再读取,不能同步读取

四、特殊:文件夹上传 webkitdirectory

如果是上传文件夹:

html 复制代码
<input type="file" webkitdirectory multiple>
  1. 必须同时加 webkitdirectory + multiple
  2. 部分浏览器选中空文件夹时 files.length=0
  3. 取消选择依然是空 FileList

快速排查步骤

1、打印 inputEl 确认元素存在

2、在 inputEl.onchange 内部打印 inputEl.files,看是否有值

  • 有值:你的代码执行时机太早(click 同步读取)
  • 无值:用户没选中文件 / 取消弹窗 / 文件被 accept 过滤

3、改用 el-upload 的 @change 钩子绕过 DOM 操作,彻底规避清空问题


问题:

html 复制代码
<el-upload
  ref="folderUploadRef"
  class="folder-upload"
  action="/api/GongshiAnalyz"
  name="files"
  :show-file-list="false"
  directory
  multiple
  :disabled="tableLoading"
  :http-request="handleCustomUpload">
</el-upload>

根源说明(你这个代码场景专属)

1. 为什么你手动取 inputEl.files 是空

核心两点:

  • el-upload

自定义上传 :http-request + directory 文件夹上传,内部会立刻清空 input.value

浏览器 input 文件一旦赋值 / 触发上传逻辑,框架会执行 input.value = '',此时外部同步去读 .files 直接变成空 FileList,length=0。

  • 执行时序错误

如果你是在点击按钮同步执行 querySelector 拿 files,弹窗还未选择文件;

就算等选择完成,组件内部优先清空 input,你外部再读就无数据。

2. 最优方案:不要手动获取 input,用 http-request 自带参数

handleCustomUpload 第一个参数里自带原生 file 对象,文件夹上传会包含全部子文件,无需操作 DOM:

TypeScript 复制代码
const handleCustomUpload = async (uploadOpt: any) => {
  // uploadOpt.file 单文件;文件夹上传多文件看 uploadOpt.fileList
  const rawFiles = uploadOpt.fileList.map(item => item.raw as File)
  console.log('文件夹全部文件', rawFiles)
  
  // 自行封装 FormData 提交
  const formData = new FormData()
  rawFiles.forEach(file => {
    formData.append('files', file)
  })
  
  // 你的 axios 请求逻辑
  // await axios.post('/api/GongshiAnalyz', formData)
}

3. 如果你业务强制要提前拿到文件(点击后、上传前校验)

监听 @change 事件,这里文件还未被组件清空,能完整获取所有文件夹文件:

html 复制代码
<el-upload
  ref="folderUploadRef"
  class="folder-upload"
  action="/api/GongshiAnalyz"
  name="files"
  :show-file-list="false"
  directory
  multiple
  :disabled="tableLoading"
  :http-request="handleCustomUpload"
  @change="onFileSelect"
>
TypeScript 复制代码
// 选中文件夹后立刻触发,files完整存在
const onFileSelect = (fileObj, uploadFileList) => {
  const allRawFiles = uploadFileList.map(item => item.raw as File)
  console.log('选中文件夹文件列表', allRawFiles)
  // 在这里做文件校验、大小过滤、格式判断等前置逻辑
}

4. 补充:如果你非要通过原生 input 获取文件(兜底写法)

必须绑定 onchange 异步回调,不能同步读取:

TypeScript 复制代码
const getFolderFiles = () => {
  const inputEl = folderUploadRef.value?.$el.querySelector('input') as HTMLInputElement
  if (!inputEl) return
  inputEl.onchange = (e: Event) => {
    const target = e.target as HTMLInputElement
    const files = Array.from(target.files || [])
    console.log('原生文件', files)
  }
  inputEl.click()
}

关键总结

directory 文件夹上传 + 自定义 http-request 的 el-upload,禁止点击后同步读取 input.files,两种稳妥方案:

  1. 前置校验:用 @change 钩子拿文件
  2. 上传提交:直接使用 handleCustomUpload 的 fileList 参数 完全避开 input 被清空导致 FileList 为空的问题。

问题:

const rawFiles = uploadOpt.fileList.map(item => item.raw as File)

里面没有 fileList 吧?

问题原因

  1. TS 报错核心UploadRequestOptions 类型定义里 不存在 fileList 字段 ,只有单个 file,所以编辑器报红提示 Property 'fileList' does not exist
  2. 控制台打印佐证 :右侧控制台能看到 uploadOpt 只有 file 对象,没有 fileList 属性,我之前那句 uploadOpt.fileList 是错误写法,给你纠正。

关键背景:el-upload 文件夹上传 + custom http-request 的机制

加了 directory 文件夹上传时,Element Plus 会 循环多次调用 http-request ,每一次回调只传入 一个文件 ,每次 uploadOpt.file 对应文件夹里其中一个文件,不会一次性把所有文件打包成 fileList 丢进 uploadOpt。

两种正确实现方案

方案 1:用 @change 一次性拿到完整文件列表(推荐,一次性获取全部文件夹文件)

template 补充 @change

html 复制代码
<el-upload
  ref="folderUploadRef"
  class="folder-upload"
  action="/api/GongshiAnalyz"
  name="files"
  :show-file-list="false"
  directory
  multiple
  :disabled="tableLoading"
  :http-request="handleCustomUpload"
  @change="handleFileChange"
>

ts 代码:

TypeScript 复制代码
// 选中文件夹后一次性拿到所有文件,不会分多次触发
const handleFileChange = (_, uploadFileList: UploadFile[]) => {
  // 全部原生File
  const allFiles = uploadFileList.map(item => item.raw as File)
  console.log('文件夹所有文件', allFiles)
  // 在这里统一校验、缓存文件,之后再统一上传
}

方案 2:在 http-request 内部收集所有文件(分次接收,自行缓存)

TypeScript 复制代码
// 定义数组缓存全部文件
const fileCache: File[] = []
const handleCustomUpload = async (uploadOpt: UploadRequestOptions) => {
  // 每次仅单个文件,push进缓存
  fileCache.push(uploadOpt.file)

  // 判断是否是最后一个文件(需要配合on-success判断全部上传完成)
  uploadOpt.onSuccess?.()
}

方案 3:如果你想一次性请求上传全部文件(不要框架分次调用 http-request)

放弃自定义 http-request,在 @change 里自己组装 FormData、手动发请求:

TypeScript 复制代码
const handleFileChange = async (_, uploadFileList: UploadFile[]) => {
  const formData = new FormData()
  uploadFileList.forEach(item => {
    formData.append('files', item.raw as File)
  })
  // 手动axios一次性提交所有文件
  await axios.post('/api/GongshiAnalyz', formData)
}

补充说明你截图里的打印

右侧控制台输出 uploadOpt 仅包含单个 file,不存在 fileList,所以:

  • uploadOpt.fileList 写法既不符合 TS 类型,运行时也取不到值;
  • 文件夹上传框架会多次执行 handleCustomUpload,每次只带一个文件。

最简修复(解决你当前 TS 报错)

删掉 .fileList,改用 uploadOpt.file 单文件接收:

TypeScript 复制代码
// 错误
// const rawFiles = uploadOpt.fileList.map(item => item.raw as File)

// 正确,单次只取一个文件
const singleFile = uploadOpt.file as File

使用方案3

方案 3 完整可运行代码(一次性上传文件夹所有文件,不触发多次 http-request)

1. Template 模板

移除 :http-request,依靠 @change 手动提交,保留 directory 文件夹上传

html 复制代码
<template>
  <el-upload
    ref="folderUploadRef"
    class="folder-upload"
    action=""
    name="files"
    :show-file-list="false"
    directory
    multiple
    :disabled="tableLoading"
    auto-upload="false"
  >
    <el-button type="primary">选择文件夹上传</el-button>
  </el-upload>
</template>

关键点:

  • action="" 置空,阻止组件自动发起请求
  • auto-upload="false" 关闭自动上传,由我们手动发请求

2. TS 完整逻辑

TypeScript 复制代码
import { ref } from 'vue'
import type { UploadFile, UploadInstance } from 'element-plus'
import axios from 'axios'

const folderUploadRef = ref<UploadInstance>()
const tableLoading = ref(false)

// 文件选中回调,一次性拿到文件夹全部文件
const handleFileChange = async (_file: UploadFile, fileList: UploadFile[]) => {
  if (!fileList.length) return
  tableLoading.value = true

  try {
    // 组装FormData
    const formData = new FormData()
    // 遍历所有文件塞进表单
    fileList.forEach(item => {
      formData.append('files', item.raw as File)
    })

    // 一次性请求后端接口
    const res = await axios.post('/api/GongshiAnalyz', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })

    console.log('上传成功', res.data)
    // 上传完成清空上传列表
    folderUploadRef.value?.clearFiles()
  } catch (err) {
    console.error('上传失败', err)
  } finally {
    tableLoading.value = false
  }
}

3. 绑定 change 事件到 el-upload

html 复制代码
<el-upload
  ...
  @change="handleFileChange"
>

4. 解决你之前遇到的全部问题

  1. 不会分多次调用上传函数:整个文件夹文件一次性拿到、一次性请求
  2. 无 TS 类型报错fileList@change 回调标准参数,Element Plus 类型内置存在
  3. 不用操作原生 input 拿文件:彻底规避 input.files 被清空长度为 0 的坑
  4. 保留文件夹原生能力:directory 会自动携带 webkitRelativePath 完整文件路径传给后端

5. 如果你需要保留 loading、弹窗提示等拓展

TypeScript 复制代码
import { ElMessage } from 'element-plus'

// try 内
ElMessage.success('文件夹解析完成')
// catch 内
ElMessage.error('上传失败,请检查文件夹内容')

补充:如果后端接口路径带前缀

直接修改请求地址即可:

TypeScript 复制代码
await axios.post('/api/v1/GongshiAnalyz', formData, {...})

问题:

为何文件夹里有多少个文件就会调用多少次后端接口呢?


核心原因:directory + 开启自动上传 / 自定义http-request 时 Element Plus 的底层逻辑

1. 根源机制

<el-upload directory multiple> 选择文件夹时:

浏览器会把文件夹内 每一个子文件单独生成一条 File 对象

Element Plus 内部默认逻辑是:一个 File 对应一次上传请求

  • 原生单文件上传:1 文件 → 1 次请求
  • 文件夹上传(多文件):N 个文件 → N 次触发上传逻辑
    • 若用默认自动上传:自动发 N 次接口
    • 若用 :http-request循环执行 N 次你的 handleCustomUpload ,每次只传入单个 uploadOpt.file

2. 为什么会这样设计?

  1. 早期浏览器文件上传限制:无法一次性批量提交带目录结构的多文件,框架采用循环分片上传兼容;
  2. Element Plus 组件设计定位:默认是「单文件逐个上传」场景,文件夹只是批量选中,没有合并批量请求逻辑;
  3. http-request 设计初衷:接管单次文件的上传行为,框架不会自动把所有文件合并成一次调用。

3. 两种场景对比

场景 A:你之前用 :http-request="handleCustomUpload"(会多次调接口)

html 复制代码
<el-upload
  directory
  :http-request="handleCustomUpload"
/>

文件夹有 10 个文件 → handleCustomUpload 执行 10 次,每次只拿到 1 个 file,如果你在函数内部直接发 axios,就会请求后端 10 次。

场景 B:方案 3 auto-upload="false" + @change(只调用 1 次接口)

html 复制代码
<el-upload
  directory
  auto-upload="false"
  @change="handleFileChange"
/>

@change 只会执行 1 次 ,第二个参数 fileList 是包含全部文件的完整数组; 我们手动循环把所有文件塞进同一个 FormData,只发起一次 axios 请求,解决多次调用接口问题。

4. 关键区分两个钩子的执行时机

  1. @change:用户选择完文件夹弹窗确认后仅触发一次,携带完整文件列表;
  2. :http-request:框架内部循环,每个文件单独触发一次

5. 补充避坑点

就算保留 http-request,也可以实现单次请求,但写法麻烦:

  1. 在外部定义数组缓存每次传入的单个 file;
  2. 监听 on-success 统计上传完成数量,全部接收完再统一发请求;
  3. 逻辑复杂、还要处理加载状态、失败重试,不如直接用 auto-upload="false" 简单稳定。

总结

文件数量 = 接口请求次数,是因为 Element Plus 对文件夹上传采用「一文件一上传」的循环执行逻辑; 关闭自动上传、用 @change 一次性收集所有文件再手动提交 FormData,即可实现整个文件夹只请求一次后端


问题:

@change 还是调用了多次啊

一、先说明核心真相:directory 文件夹下 @change 天然每个文件触发一次

Element Plus 底层机制:

开启 directory 选择文件夹后,浏览器解析出 N 个文件,组件会循环 N 次执行 @change ,每次回调第一个参数是单个文件,第二个 fileList 是不断追加的完整数组(第 1 次 1 个、第 2 次 2 个...... 第 N 次全部)。

你之前在 @change 里直接发 axios,就会每触发一次调一次接口,等于文件多少就请求多少次。

为什么会这样?

多文件 / 文件夹场景下,组件设计是增量更新文件列表:每解析完一个文件就触发一次 change,同步更新内部 fileList,方便实时展示上传列表,不是弹窗确认完只触发一次。

二、3 套可靠解决方案(按推荐度排序)

方案 1:防抖 延时统一上传(不用额外按钮,最贴合你原有代码)【也能实现】

每次 change 只收集文件,延迟 300ms 无新文件追加后再统一发一次请求:

html 复制代码
<el-upload
  ref="folderUploadRef"
  class="folder-upload"
  action=""
  name="files"
  :show-file-list="false"
  directory
  multiple
  :disabled="tableLoading"
  auto-upload="false"
  @change="handleFileChange"
/>
TypeScript 复制代码
import { ref } from 'vue'
import type { UploadFile, UploadInstance } from 'element-plus'
import axios from 'axios'

const folderUploadRef = ref<UploadInstance>()
const tableLoading = ref(false)
let uploadTimer: number | null = null

const handleFileChange = (_file: UploadFile, fullFileList: UploadFile[]) => {
  // 每次触发先清除旧定时器,等待后续文件解析完成
  if (uploadTimer) clearTimeout(uploadTimer)

  // 300ms内没有新文件追加,代表文件夹解析完毕,统一上传
  uploadTimer = window.setTimeout(async () => {
    if (!fullFileList.length || tableLoading.value) return
    tableLoading.value = true
    try {
      const formData = new FormData()
      fullFileList.forEach(item => {
        formData.append('files', item.raw as File)
      })
      // 仅执行1次接口请求
      const res = await axios.post('/api/GongshiAnalyz', formData, {
        headers: { 'Content-Type': 'multipart/form-data' }
      })
      console.log('上传成功', res.data)
      folderUploadRef.value?.clearFiles()
    } catch (err) {
      console.error('上传失败', err)
    } finally {
      tableLoading.value = false
    }
  }, 300)
}

方案 2:增加独立上传按钮(最稳妥,无定时器兼容问题)

选择文件夹只缓存文件,点击按钮才一次性提交,完全规避多次触发:

html 复制代码
<el-upload
  ref="folderUploadRef"
  class="folder-upload"
  action=""
  name="files"
  :show-file-list="false"
  directory
  multiple
  :disabled="tableLoading"
  auto-upload="false"
  @change="cacheFileList"
>
  <el-button>选择文件夹</el-button>
</el-upload>
<el-button type="primary" @click="submitAllFiles" :disabled="tableLoading || !tempFileList.length">
  确认上传
</el-button>
TypeScript 复制代码
const tableLoading = ref(false)
const tempFileList = ref<UploadFile[]>([])
const folderUploadRef = ref<UploadInstance>()

// 仅缓存文件,不发请求
const cacheFileList = (_file: UploadFile, list: UploadFile[]) => {
  tempFileList.value = [...list]
}

// 点击按钮一次性上传全部
const submitAllFiles = async () => {
  if (!tempFileList.value.length || tableLoading.value) return
  tableLoading.value = true
  try {
    const formData = new FormData()
    tempFileList.value.forEach(item => formData.append('files', item.raw as File))
    await axios.post('/api/GongshiAnalyz', formData)
    folderUploadRef.value?.clearFiles()
    tempFileList.value = []
  } finally {
    tableLoading.value = false
  }
}

⭐️方案 3:原生 input 劫持(彻底绕开 el-upload 多 change 机制)⭐️

不依赖组件 change,直接监听原生 input 的 change,弹窗确认后只触发一次,

拿到完整 FileList:

html 复制代码
<el-upload
  ref="folderUploadRef"
  class="folder-upload"
  action=""
  :show-file-list="false"
  directory multiple auto-upload="false"
>
  <el-button>选择文件夹上传</el-button>
</el-upload>
TypeScript 复制代码
const folderUploadRef = ref<UploadInstance>()
const tableLoading = ref(false)

// 绑定原生input一次性获取全部文件
const triggerFolderUpload = () => {
  const input = folderUploadRef.value?.$el.querySelector('input') as HTMLInputElement
  if (!input) return
  // 每次点击重置事件,防止重复绑定
  input.onchange = async (e) => {
    const target = e.target as HTMLInputElement
    const files = Array.from(target.files || [])
    if (!files.length) return
    tableLoading.value = true
    try {
      const formData = new FormData()
      files.forEach(file => formData.append('files', file))
      await axios.post('/api/GongshiAnalyz', formData)
    } finally {
      tableLoading.value = false
    }
  }
  // input.click()
}

按钮点击事件 替换为 @click="triggerFolderUpload" ,不再依赖组件 @change

三、避坑关键点

  1. 不要在 @change 回调里直接发起请求,必然多次调用接口;
  2. 防抖延时推荐 300ms,足够浏览器解析完文件夹内所有子文件;
  3. fullFileList 是完整数组,哪怕多次触发,最后一次一定包含全部文件;
  4. 不要混用 auto-upload="true" + http-request,会双重循环请求。

最简推荐实现

优先用 方案 1 防抖版,不用新增按钮,改动最小,实现一次请求上传整个文件夹。

最终项目使用 方案 3:原生版


html 复制代码
<el-upload
  ref="folderUploadRef"
  class="folder-upload"
  :show-file-list="false"
  directory
  multiple
  auto-upload="false"
>
  <el-button type="primary" @click="triggerFolderUpload" :disabled="tableLoading">
    选择文件夹上传
  </el-button>
</el-upload>
TypeScript 复制代码
const folderUploadRef = ref<UploadInstance>()
const tableLoading = ref(false)

// 绑定原生input一次性获取全部文件
const triggerFolderUpload = () => {
  const input = folderUploadRef.value?.$el.querySelector('input') as HTMLInputElement
  if (!input) return
  // 每次点击重置事件,防止重复绑定
  input.onchange = async (e) => {
    const target = e.target as HTMLInputElement
    const files = Array.from(target.files || [])
    if (!files.length) return
    tableLoading.value = true
    try {
      const formData = new FormData()
      files.forEach(file => formData.append('files', file))
      await axios.post('/api/GongshiAnalyz', formData)
    } finally {
      tableLoading.value = false
    }
  }
}