图片、文件上传

图片上传原理

前端图片上传,本质只有一件事 :把浏览器里的 File / Blob,用 HTTP 请求发送到后端(或对象存储)

不同实现方式,只是在这 3 步上做文章:

  1. 怎么拿到图片

  2. 怎么处理图片(压缩 / 校验 / 转格式)

  3. 怎么发给后端

前端图片上传通常是通过 <input type="file"> 或上传组件获取 File 对象,在上传前进行格式和大小校验,必要时通过 Canvas 或第三方库如Compressor进行压缩,然后使用 FormData 以 multipart/form-data 的方式通过 HTTP 请求发送到后端,后端解析文件并存储,最后返回图片访问地址。

核心流程:选择图片 → 校验 / 压缩 → 封装 FormData → 发送 POST 请求 → 后端解析 / 存储 → 返回在线 URL → 前端更新展示。

前端直传 OSS

在中大型项目中,通常会采用前端直传 OSS 的方式,由后端生成上传凭证,前端直接上传,减少服务器压力并提升性能。

前端 → 业务后端(获取OSS临时凭证)→ 前端直传OSS → 业务后端(确认上传结果)

图片直接从前端传到 OSS,业务后端只负责「签发上传凭证」,不处理图片数据;上传速度快、业务服务器无压力、支持大文件 / 断点续传。

OSS是什么

OSS = Object Storage Service(对象存储服务),是一种基于「对象」而非传统文件系统或块存储的云原生存储服务,核心用于存储和管理海量非结构化数据(如图片、视频、文档、日志等)

OSS 直传的核心条件只有一句话:浏览器必须能直接访问 OSS 域名。

内网不能"前端直传 OSS"

表单中,图片上传(代码实现)

采用的是------FormData 封装 + el-upload 原生上传(multipart/form-data 格式)

javascript 复制代码
<template>

  <el-dialog

    v-model="dialogFormVisible"

    draggable

    title="图片上传"

    width="700px"

    @close="onClose"

  >

    <el-form ref="refForms" label-width="130px" :model="form">

      <el-form-item label="上传图片">

        <el-upload

          v-model:file-list="imageList"

          list-type="picture-card"

          :limit="4"

          :action="uploadUrl"

          :headers="headers"

          name="files"

          :before-upload="beforeImageUpload"

          :on-exceed="onImageExceed"

          accept="image/jpeg,image/png,image/gif"

          :on-preview="handlePictureCardPreview"

          :on-remove="handleRemoveImage"

          :on-success="handleImageSuccess"

          :on-error="handleImageError"

        >

          <el-icon><Plus /></el-icon>

        </el-upload>

      </el-form-item>

    </el-form>


    <template #footer>

      <el-button @click="onClose">取 消</el-button>

      <el-button

        type="primary"

        :disabled="submitLoading"

        :loading="submitLoading"

        @click="onSave"

      >

        提交

      </el-button>

    </template>

  </el-dialog>


  <ElImageViewer

    v-if="showViewer"

    :initial-index="initialIndex"

    :url-list="previewSrcList"

    @close="() => (showViewer = false)"

  />

</template>


<script setup>

  import { ref, reactive, inject, computed } from 'vue'

  import { Plus } from '@element-plus/icons-vue'

  import { ElImageViewer } from 'element-plus'

  import { FlowNextFlowApi } from '@/api/xx'

  import { useUserStore } from '@/store/modules/user'

  import Compressor from 'compressorjs'


  const $baseMessage = inject('$baseMessage')

  const dialogFormVisible = ref(true)

  const refForms = ref(null)


  const submitLoading = ref(false)


  const { token } = useUserStore()

  const headers = { Authorization: `Bearer ${token}` }

  const uploadUrl = `${

    import.meta.env.VITE_API_BASE_URL

  }/xx/xx`


  const form = reactive({

    imageList: [],

  })


  const imageList = ref([])

  const showViewer = ref(false)

  const initialIndex = ref(0)

  const previewSrcList = computed(() => {

    return imageList.value.map((item) => item.url)

  })



  const showMsgOK = (msg) => {

    $baseMessage(msg, 'success', 'vab-hey-message-success')

  }

  const showMsgError = (msg) => {

    $baseMessage(msg, 'error', 'vab-hey-message-error')

  }


  const resetFields = () => {

    form.imageList = []

    imageList.value = []

    showViewer.value = false

    refForms.value?.resetFields()

    submitLoading.value = false

  }


  const onClose = () => {

    resetFields()

    dialogFormVisible.value = false

  }


  /* ==================== 重命名方法 ==================== */

  /**

   * 绝对安全的文件名处理:过滤所有非法特殊字符 + 生成唯一安全文件名

   * @param {File} file 文件对象

   * @returns {File} 安全文件对象

   */

  const formatFileName = (file) => {

    // 1. 获取文件后缀,健壮写法,兼容无后缀的文件

    const dotIndex = file.name.lastIndexOf('.')

    const suffix =

      dotIndex === -1 ? '' : file.name.substring(dotIndex).toLowerCase()

    // 2. 过滤原始文件名中的【所有非法特殊字符】,只保留 字母、数字、中文、下划线

    const pureName = file.name

      .substring(0, dotIndex === -1 ? file.name.length : dotIndex)

      .replace(/[^\u4e00-\u9fa5a-zA-Z0-9_]/g, '')

    // 3. 生成安全文件名:纯名称 + 时间戳 + 后缀

    const prefix = 'img_'

    const safeFileName = `${prefix}${pureName}_${new Date().getTime()}${suffix}`

    // 4. 生成新文件对象,内容不变,文件名安全

    return new File([file], safeFileName, { type: file.type })

  }


  // 图片上传前置校验

  const beforeImageUpload = (file) => {

    const allowTypes = ['image/jpeg', 'image/png', 'image/gif']

    const isValidType = allowTypes.includes(file.type)


    if (!isValidType) {

      showMsgError('仅支持 jpg / png / gif 图片')

      return false

    }

    // GIF动图直接放行,保留动画;JPG/PNG用compressorjs压缩

    if (file.type === 'image/gif') {

      const safeFile = formatFileName(file)

      return Promise.resolve(safeFile)

    }

    // 图片压缩 + 安全重命名

    return new Promise((resolve, reject) => {

      new Compressor(file, {

        quality: 0.7,

        maxWidth: 1920,

        maxHeight: 1920,

        preserveExtensions: true,

        success(result) {

          const safeFile = formatFileName(result)

          resolve(safeFile)

        },

        error(err) {

          showMsgError('图片压缩失败,请重新上传')

          reject(err)

        },

      })

    })

  }


  // 图片数量超限提示

  const onImageExceed = () => {

    showMsgError('最多只能上传 4 张图片')

  }


  // 图片上传成功回调

  const handleImageSuccess = (response, uploadFile, uploadFiles) => {

    if (!response.IsSuccess) {

      showMsgError(response.ErrorMessage)

      return

    }

    const imgUrl = response.Data[0] || ''

    const currIndex = uploadFiles.findIndex(

      (item) => item.uid === uploadFile.uid

    )

    uploadFiles[currIndex].url = imgUrl

    uploadFiles[currIndex].name = uploadFile.name

    imageList.value = uploadFiles

    // 上传成功的图片地址存入form.imageList

    if (imgUrl && !form.imageList.includes(imgUrl)) {

      form.imageList.push(imgUrl)

    }

  }


  // 图片上传失败回调

  const handleImageError = (err, uploadFile) => {

    showMsgError(`图片【${uploadFile.name}】上传失败,请检查网络后重试`)

  }


  // 图片删除回调

  const handleRemoveImage = (file, fileList) => {

    imageList.value = fileList

    if (file.url && form.imageList.includes(file.url)) {

      form.imageList = form.imageList.filter((url) => url !== file.url)

    }

  }


  // 图片预览

  const handlePictureCardPreview = (uploadFile) => {

    initialIndex.value = imageList.value.findIndex(

      (s) => s.uid === uploadFile.uid

    )

    showViewer.value = true

  }



  // 提交按钮事件

  const onSave = async () => {

    const valid = await refForms.value.validate()

    if (!valid) return


    // 校验图片是否全部上传完成

    const isAllImageUploaded = imageList.value.every((item) => item.url)

    if (!isAllImageUploaded) {

      showMsgError('请等待所有图片上传完成后再提交!')

      return

    }


    try {

      submitLoading.value = true

      // 调用提交接口

      const res = await FlowNextFlowApi(form)

      const { success, msg } = res.data

      if (success) {

        showMsgOK(msg)

        onClose()

      } else {

        showMsgError(msg)

      }

    } catch (e) {

      showMsgError(e.message || '提交失败,请重试')

    } finally {

      submitLoading.value = false

    }

  }


  const showDialog = () => {

    dialogFormVisible.value = true

    submitLoading.value = false

  }


  defineExpose({

    showDialog,

  })

</script>

FormData 是「自动封装」的:你代码中没有显式写 new FormData(),但 el-upload 组件在原生上传模式下会自动完成这一步 ------ 把 beforeImageUpload 返回的安全文件对象,以 name="files" 为键名,封装进 FormData。

图片压缩canvas与compressorjs

前端图片压缩,本质上只做 三件事:

  1. 降低分辨率(width / height)

  2. 降低编码质量(JPEG quality)

  3. 改变编码格式(PNG → JPEG / WebP)

前端不能做真正的"无损高效压缩算法优化",那是 libjpeg / mozjpeg / cjpeg 级别的事情(属于后端或构建期)

压缩的主流方案

canvas

本地图片File → FileReader转base64 → new Image()加载base64 → Canvas绘制图片 → canvas.toBlob()生成压缩后的Blob → new File()转成最终File对象

浏览器解码图片->Canvas 重新绘制->使用新的编码参数导出

优点:

  • 零依赖:纯原生 JS+Canvas API 实现,不用安装任何第三方包,项目无体积增加;

  • 浏览器原生支持

  • 可完全自定义(尺寸、裁剪、质量)

存在的问题

  • canvas.toBlob 对 png/gif 格式的处理逻辑存在缺陷,canvas.toBlob(blob, file.type, 0.7) 这个 API 中,quality:0.7 质量参数,只对 jpeg/jpg 格式生效 ,对 png/gif 完全无效;

  • 对 png/gif 执行这个方法时,canvas 会把图片重新编码为「无损位图」,丢掉原图的压缩算法,导致 png/gif 图片体积变大

  • Canvas 处理 GIF 会变成静态图,Canvas 只会取 GIF 第一帧

  • PNG 透明背景会「变黑」,Canvas 画布默认是白色背景,绘制透明 PNG 后导出时,透明区域会被填充为黑色,需要手动写兼容代码修复;

javascript 复制代码
const beforeImageUpload = (file) => {

    const allowTypes = ['image/jpeg', 'image/png', 'image/gif']

    const isValidType = allowTypes.includes(file.type)

    if (!isValidType) {

      showMsgError('仅支持 jpg / png / gif 图片')

      return false

    }

    return new Promise((resolve) => {

      const reader = new FileReader()

      reader.readAsDataURL(file)

      reader.onload = (e) => {

        const img = new Image()

        img.src = e.target.result

        img.onload = () => {

          const canvas = document.createElement('canvas')

          canvas.width = img.width

          canvas.height = img.height

          const ctx = canvas.getContext('2d')

          ctx.drawImage(img, 0, 0)

          canvas.toBlob(

            (blob) => {

              const compressFile = new File([blob], file.name, {

                type: file.type,

              })

              resolve(compressFile)

            },

            file.type,

            0.7

          )

        }

      }

    })

  }

compressorjs

它的核心流程和原生 Canvas 一致,但是内部做了大量优化:比如自动兼容格式、自动处理透明背景、自动判断图片大小是否需要压缩、自动等比缩放等。

智能适配图片格式,不会让 PNG/GIF 体积变大,内部做了格式兼容,对 PNG/GIF (无损格式) 自动采用「无损压缩 + 尺寸优化」,不会改变图片质量,体积只会变小 / 不变,绝对不会变大;对 JPG/JPEG (有损格式) 才会用quality做质量压缩,体积大幅减小。

javascript 复制代码
import Compressor from 'compressorjs'        

  const beforeImageUpload = (file) => {

    const allowTypes = ['image/jpeg', 'image/png', 'image/gif']

    const isValidType = allowTypes.includes(file.type)


    if (!isValidType) {

      showMsgError('仅支持 jpg / png / gif 图片')

      return false

    }

    return new Promise((resolve, reject) => {

      new Compressor(file, {

        quality: 0.7,// 压缩质量 0-1,值越小体积越小,0.7是兼顾画质和体积的黄金值

        maxWidth: 1920,// 可选:限制最大宽度,超过则等比缩小,不压缩宽度则注释

        maxHeight: 1920,// 可选:限制最大高度,超过则等比缩小,不压缩高度则注释

        preserveExtensions: true,

        success(result) {

          resolve(result)

        },

        error(err) {

          showMsgError('图片压缩失败,请重新上传')

          reject(err)

        },

      })

    })

  }

compressorjs 也「不支持 GIF 动图」,效果和原生 Canvas 一致

compressorjs 底层核心还是基于 Canvas 实现的,它的压缩逻辑本质也是:读文件 → 转 Canvas 绘制 → 导出 Blob,解决不了「动图变静态」的问题。所有基于 Canvas 绘制的压缩方案,都「无法保留 GIF 动图的动画效果」

解决方案一:使用专业 GIF 处理库 gif.js / gif-compressor(如果你的业务中 GIF 动图很多、且体积普遍很大使用)

javascript 复制代码
// 安装:npm install gif-compressor --save

import Compressor from 'compressorjs'

import GIFCompressor from 'gif-compressor'


const beforeImageUpload = (file) => {

  const allowTypes = ['image/jpeg', 'image/png', 'image/gif'];

  const isValidType = allowTypes.includes(file.type);

  if (!isValidType) { showMsgError('仅支持 jpg / png / gif 图片'); return false; }

  return new Promise((resolve, reject) => {

    // 判断是否是GIF动图

    if (file.type === 'image/gif') {

      // GIF动图:用专门的库压缩,保留动画

      GIFCompressor(file, {

        quality: 0.7, // 压缩质量

        maxSize: 5 * 1024 * 1024, // 限制5MB

        success: (result) => resolve(result), // result是File对象,保留原名+动画

        error: (err) => { showMsgError('GIF压缩失败'); reject(err) }

      })

    } else {

      // JPG/PNG静态图:继续用compressorjs,体验不变

      new Compressor(file, {

        quality: 0.7,

        success: (result) => resolve(result),

        error: (err) => { showMsgError('图片压缩失败'); reject(err) }

      })

    }

  });

};

解决方案二:对 GIF 动图「跳过压缩,直接放行」(推荐使用)

javascript 复制代码
import Compressor from 'compressorjs'


const beforeImageUpload = (file) => {

  const allowTypes = ['image/jpeg', 'image/png', 'image/gif'];

  const isValidType = allowTypes.includes(file.type);

  if (!isValidType) { showMsgError('仅支持 jpg / png / gif 图片'); return false; }


  // ===== GIF动图 直接放行,不压缩,保留动画 =====

  if (file.type === 'image/gif') {

    return Promise.resolve(file); // 直接返回原文件,动画100%保留

  }


  // JPG/PNG 继续用compressorjs压缩,体积变小,保留原名

  return new Promise((resolve, reject) => {

    new Compressor(file, {

      quality: 0.7,

      success(result) { resolve(result) },

      error(err) { showMsgError('图片压缩失败'); reject(err) }

    });

  });

};

文件上传为什么必须写 FormData

上传文件 = 必须用 FormData,不用 FormData,文件本身是传不到后端的

  • 普通接口:传的是文本数据(JSON / 字符串),请求头是 application/json;

  • 文件上传:传的是二进制数据(文件的字节流),必须用 multipart/form-data 格式传输;

option.file 是一个 File 对象,JSON 无法承载二进制文件,必须通过 FormData 由浏览器自动封装 boundary 才能被后端正确解析。

javascript 复制代码
option.file instanceof File // true

FormData 是 HTML5 新增的内置对象,FormData 是浏览器提供的用于构造 multipart/form-data 请求体的对象,是前端上传文件给后端的"标准容器"。

作用是:

  • 把你的文件(option.file)按照 multipart/form-data 格式打包;
  • 给文件绑定一个「参数名」(files),后端通过这个参数名才能拿到文件;
  • 让 axios 能正确识别「这是文件上传请求」,配合 Content-Type: multipart/form-data 请求头,把文件完整传给后端。

没有 FormData,你的文件就无法以正确格式传给后端,后端接收到的只是一堆乱码,根本解析不出文件!

图片上传-oss直传

javascript 复制代码
<template>
  <el-dialog
    v-model="dialogFormVisible"
    draggable
    title="图片OSS直传"
    width="700px"
    @close="onClose"
  >
    <el-form
      ref="refForms"
      label-width="130px"
      :model="form"
      :rules="formRules"
    >
      <el-form-item label="上传凭证图片" prop="imageList">
        <el-upload
          v-model:file-list="imageList"
          list-type="picture-card"
          :limit="4"
          action=""
          :before-upload="beforeImageUpload"
          :on-exceed="onImageExceed"
          accept="image/jpeg,image/png,image/gif"
          :on-preview="handlePictureCardPreview"
          :on-remove="handleRemoveImage"
          :http-request="ossDirectUpload"
          :disabled="submitLoading"
        >
          <el-icon><Plus /></el-icon>
        </el-upload>
        <div class="el-upload__tip" style="margin-top: 10px">
          仅支持 jpg/png/gif 格式,单张不超过 5MB,最多上传 4 张
        </div>
      </el-form-item>
    </el-form>

    <template #footer>
      <el-button @click="onClose">取 消</el-button>
      <el-button type="primary" :loading="submitLoading" @click="onSave">
        提 交
      </el-button>
    </template>
  </el-dialog>

  <!-- 图片预览组件 -->
  <ElImageViewer
    v-if="showViewer"
    :initial-index="initialIndex"
    :url-list="previewSrcList"
    @close="() => (showViewer = false)"
  />
</template>


<script setup>
  /************************** 基础依赖导入 **************************/
  import { ref, reactive, computed } from 'vue'
  import { Plus } from '@element-plus/icons-vue'
  import { ElImageViewer, ElMessage } from 'element-plus'
  import Compressor from 'compressorjs' // 图片压缩库(需安装:npm i compressorjs)
  import axios from 'axios' // 请求库(需安装:npm i axios)


  /************************** 业务依赖(需替换为你的实际路径) **************************/
  // 1. 流程提交API:替换为你的后端提交接口
  // import { FlowNextFlowApi } from '@/api/xx'
  // 2. OSS凭证获取API:替换为你的后端获取OSS临时凭证接口
  // import { getOssUploadAuth } from '@/api/xx'
  // 3. 用户store:替换为你的token获取方式
  // import { useUserStore } from '@/store/modules/user'


  /************************** 模拟依赖(本地测试用,实际项目删除) **************************/
  // 模拟获取token(实际项目用useUserStore)
  const useUserStore = () => ({
    token: localStorage.getItem('token') || 'test-token-123456',
  })
  // 模拟流程提交API(实际项目替换为真实接口)
  const FlowNextFlowApi = async (form) => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({ data: { success: true, msg: '审核提交成功' } })
      }, 1000)
    })
  }
  // 模拟OSS凭证获取API(实际项目替换为真实接口)
  const getOssUploadAuth = async () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({
          data: {
            success: true,
            data: {
              accessKeyId: 'STS.Nxxxxxxxxx', // 替换为你的OSS临时AK
              policy:
                'eyJleHBpcmF0aW9uIjoiMjAyNi0wMS0yMFQxMi4wMFoiLCJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJtest-bucket-nameIn0seyJ4LWFtei1jcmVkZW50aWFsLXVzZXJuYW1lIjoiYWRtaW4ifV19', // 替换为你的policy
              signature: 'Zxxxxxxxxxxxxxxxxxxxx', // 替换为你的签名
              host: 'https://test-bucket-name.oss-cn-hangzhou.aliyuncs.com', // 替换为你的OSS域名
              dir: `upload/${userId}/${yyyyMMdd}/`, // 存储路径前缀
            },
          },
        })
      }, 500)
    })
  }


  /************************** 核心状态管理 **************************/
  const dialogFormVisible = ref(false)
  const refForms = ref(null)
  const submitLoading = ref(false)
  // 鉴权请求头(token)
  const { token } = useUserStore()
  const headers = { Authorization: `Bearer ${token}` }


  // 表单数据:仅存储图片OSS URL
  const form = reactive({
    imageList: [],
  })


  // 图片上传列表(用于预览/展示)
  const imageList = ref([])
  // 图片预览相关状态
  const showViewer = ref(false)
  const initialIndex = ref(0)
  // 预览图片URL列表(计算属性)
  const previewSrcList = computed(() => {
    return imageList.value.map((item) => item.url || '')
  })


  // 表单校验规则
  const formRules = reactive({
    imageList: [
      { required: true, message: '请至少上传1张图片', trigger: 'change' },
    ],
  })



  /************************** 消息提示封装 **************************/
  const showMsgOK = (msg) => {
    ElMessage({
      message: msg,
      type: 'success',
      customClass: 'vab-hey-message-success',
    })
  }
  const showMsgError = (msg) => {
    ElMessage({
      message: msg,
      type: 'error',
      customClass: 'vab-hey-message-error',
    })
  }


  // 重置所有状态
  const resetFields = () => {
    form.imageList = []
    imageList.value = []
    showViewer.value = false
    submitLoading.value = false // 重置提交状态
    refForms.value?.resetFields()
  }


  const onClose = () => {
    resetFields()
    dialogFormVisible.value = false
  }


  /************************** 文件名安全重命名 **************************/
  const formatFileName = (file) => {
    // 1. 获取文件后缀(兼容无后缀文件)
    const dotIndex = file.name.lastIndexOf('.')
    const suffix =
      dotIndex === -1 ? '' : file.name.substring(dotIndex).toLowerCase()
    // 2. 过滤非法字符:仅保留中文、字母、数字、下划线
    const pureName = file.name
      .substring(0, dotIndex === -1 ? file.name.length : dotIndex)
      .replace(/[^\u4e00-\u9fa5a-zA-Z0-9_]/g, '')
    // 3. 生成安全文件名:前缀 + 纯名称 + 时间戳 + 后缀
    const prefix = 'audit_img_'
    const safeFileName = `${prefix}${pureName}_${new Date().getTime()}${suffix}`
    // 4. 返回新文件对象(内容不变,仅修改名称)
    return new File([file], safeFileName, { type: file.type })
  }


  /************************** 图片上传前置校验/压缩 **************************/
  const beforeImageUpload = (file) => {
    // 1. 基础校验:格式 + 大小
    const allowTypes = ['image/jpeg', 'image/png', 'image/gif']
    const isValidType = allowTypes.includes(file.type)
    const isLt5M = file.size / 1024 / 1024 < 5


    if (!isValidType) {
      showMsgError('仅支持 jpg / png / gif 格式图片')
      return false
    }
    if (!isLt5M) {
      showMsgError('图片大小不能超过 5MB')
      return false
    }


    // 2. GIF动图直接安全重命名,不压缩(保留动画)
    if (file.type === 'image/gif') {
      const safeFile = formatFileName(file)
      return Promise.resolve(safeFile)
    }


    // 3. JPG/PNG图片:压缩 + 安全重命名
    return new Promise((resolve, reject) => {
      new Compressor(file, {
        quality: 0.7, // 压缩质量(0-1,值越小体积越小)
        maxWidth: 1920, // 最大宽度,超出自动缩放
        maxHeight: 1920, // 最大高度
        preserveExtensions: true, // 保留文件后缀
        success(result) {
          const safeFile = formatFileName(result)
          resolve(safeFile)
        },
        error(err) {
          showMsgError('图片压缩失败,请重新上传')
          reject(err)
        },
      })
    })
  }


  /************************** 图片数量超限提示 **************************/
  const onImageExceed = () => {
    showMsgError('最多只能上传 4 张图片')
  }


  /************************** 核心:OSS直传逻辑 **************************/
  const ossDirectUpload = async (option) => {
    try {
      // 1. 获取OSS上传临时凭证
      const ossAuth = await getOssUploadAuth()
      if (!ossAuth || !ossAuth.data.success) {
        showMsgError('获取上传凭证失败,请重试')
        option.onError('获取凭证失败')
        return
      }
      const { accessKeyId, policy, signature, host, dir } = ossAuth.data.data


      // 2. 封装OSS上传FormData(阿里云OSS固定格式)
      const formData = new FormData()
      formData.append('OSSAccessKeyId', accessKeyId) // 临时AK
      formData.append('policy', policy) // 上传策略
      formData.append('Signature', signature) // 签名
      formData.append('success_action_status', '200') // OSS返回200表示成功
      formData.append('key', `${dir}${option.file.name}`) // OSS存储路径
      formData.append('file', option.file) // 处理后的图片文件


      // 3. 前端直传OSS(核心:请求地址是OSS域名,非业务后端)
      const response = await axios.post(host, formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
        timeout: 60000, // 超时时间60秒
        // 可选:上传进度展示
        onUploadProgress: (progressEvent) => {
          const progress = Math.round(
            (progressEvent.loaded / progressEvent.total) * 100
          )
          console.log(`【${option.file.name}】上传进度:${progress}%`)
        },
      })


      // 4. 处理上传结果,拼接图片访问URL
      if (response.status === 200) {
        const imgUrl = `${host}/${dir}${option.file.name}`
        // 更新图片列表
        const currIndex = imageList.value.findIndex(
          (item) => item.uid === option.file.uid
        )
        if (currIndex > -1) {
          imageList.value[currIndex].url = imgUrl
          imageList.value[currIndex].name = option.file.name
        } else {
          imageList.value.push({
            uid: option.file.uid,
            name: option.file.name,
            url: imgUrl,
            raw: option.file,
          })
        }
        // 存入表单(用于最终提交)
        if (imgUrl && !form.imageList.includes(imgUrl)) {
          form.imageList.push(imgUrl)
        }
        // 通知组件上传成功
        option.onSuccess(response)
        showMsgOK(`【${option.file.name}】上传成功`)
      } else {
        throw new Error('OSS上传失败')
      }
    } catch (error) {
      showMsgError(
        `图片【${option.file.name}】上传失败:${error.message || '网络错误'}`
      )
      option.onError(error)
    }
  }


  /************************** 图片移除逻辑 **************************/
  const handleRemoveImage = (file, fileList) => {
    imageList.value = fileList
    // 同步移除表单中的URL
    if (file.url && form.imageList.includes(file.url)) {
      form.imageList = form.imageList.filter((url) => url !== file.url)
    }
  }


  /************************** 图片预览逻辑 **************************/
  const handlePictureCardPreview = (uploadFile) => {
    initialIndex.value = imageList.value.findIndex(
      (s) => s.uid === uploadFile.uid
    )
    showViewer.value = true
  }


  /************************** 核心:提交逻辑 **************************/
  const onSave = async () => {
    if (submitLoading.value) return

    let valid = false
    try {
      valid = await refForms.value.validate()
    } catch (e) {
      valid = false
    }
    if (!valid) return


    // 校验所有图片是否上传完成
    const isAllImageUploaded = imageList.value.every((item) => item.url)
    if (!isAllImageUploaded) {
      showMsgError('请等待所有图片上传完成后再提交!')
      return
    }

    try {
      submitLoading.value = true

      // 调用业务后端接口,提交图片URL
      const res = await FlowNextFlowApi(form)
      const { success, msg } = res.data
      if (success) {
        showMsgOK(msg)
        onClose() // 提交成功关闭弹窗
      } else {
        showMsgError(msg)
      }
    } catch (e) {
      showMsgError(e.message || '提交失败,请重试')
    } finally {
      // 无论成功/失败,重置提交状态
      submitLoading.value = false
    }
  }


  const showDialog = () => {
    resetFields() // 打开前重置状态
    dialogFormVisible.value = true
  }


  defineExpose({
    showDialog,
  })
</script>


<style scoped>
  .vab-hey-message-success {
    --el-message-bg-color: #f0f9ff;
    --el-message-text-color: #10b981;
  }
  .vab-hey-message-error {
    --el-message-bg-color: #fef2f2;
    --el-message-text-color: #ef4444;
  }
</style>

表单中,文件上传(代码实现)

javascript 复制代码
<template>
  <el-dialog
    v-model="dialogFormVisible"
    draggable
    title="文件上传"
    width="700px"
    @close="onClose"
  >
    <el-form ref="refForms" label-width="130px" :model="form">
      <el-form-item label="上传附件">
        <el-upload
          v-model:file-list="fileList"
          :limit="4"
          style="width: 100%"
          class="upload-demo"
          action=""
          :before-upload="beforeFileUpload"
          :on-exceed="onFileExceed"
          accept=".pdf,.xls,.xlsx,.doc,.docx,.txt,.csv,.zip,.rar"
          :on-remove="handleRemoveFile"
          :http-request="httpRequest"
          :on-error="handleFileError"
        >
          <el-button type="primary">上传文件</el-button>
        </el-upload>
      </el-form-item>
    </el-form>

    <template #footer>
      <el-button @click="onClose">取 消</el-button>
      <el-button
        type="primary"
        :loading="submitLoading"
        :disabled="submitLoading"
        @click="onSave"
      >
        提交
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup>
  import { ref, reactive, inject } from 'vue'
  import { FlowNextFlowApi } from '@/api/xx'
  import { useUserStore } from '@/store/modules/user'
  import axios from 'axios'

  const emit = defineEmits(['fetch-data'])
  const $baseMessage = inject('$baseMessage')

  const dialogFormVisible = ref(false)
  const refForms = ref(null)

  const submitLoading = ref(false)

  const { token } = useUserStore()
  const headers = { Authorization: `Bearer ${token}` }
  const uploadUrl = `${
    import.meta.env.VITE_API_BASE_URL
  }/xx/xx`


  const form = reactive({
    attachs: [], // 仅保留文件的url+name集合
  })

  const fileList = ref([])

  const showMsgOK = (msg) => {
    $baseMessage(msg, 'success', 'vab-hey-message-success')
  }
  const showMsgError = (msg) => {
    $baseMessage(msg, 'error', 'vab-hey-message-error')
  }

  const resetFields = () => {
    Object.assign(form, {
      attachs: [],
    })
    fileList.value = []
    refForms.value?.resetFields()
    submitLoading.value = false
  }

  const onClose = () => {
    resetFields()
    dialogFormVisible.value = false
  }

  /* ==================== 安全文件名净化重命名方法 ==================== */
  const formatFileName = (file, isImage = false) => {
    const dotIndex = file.name.lastIndexOf('.')
    const suffix =
      dotIndex === -1 ? '' : file.name.substring(dotIndex).toLowerCase()
    const pureName = file.name
      .substring(0, dotIndex === -1 ? file.name.length : dotIndex)
      .replace(/[^\u4e00-\u9fa5a-zA-Z0-9_]/g, '')
    const prefix = isImage ? 'img_' : 'doc_'
    const safeFileName = `${prefix}${pureName}_${new Date().getTime()}${suffix}`
    return new File([file], safeFileName, { type: file.type })
  }

  /* ==================== 文件上传所有相关逻辑 ==================== */
  const beforeFileUpload = (file) => {
    const allowExt = [
      'pdf',
      'xls',
      'xlsx',
      'doc',
      'docx',
      'txt',
      'csv',
      'zip',
      'rar',
    ]
    const dotIndex = file.name.lastIndexOf('.')
    const ext =
      dotIndex === -1 ? '' : file.name.substring(dotIndex + 1).toLowerCase()
    const isValidType = allowExt.includes(ext)
    const isLt20M = file.size / 1024 / 1024 < 20

    if (!isValidType) {
      showMsgError(
        '不支持的文件格式,仅支持pdf/xls/xlsx/doc/docx/txt/csv/zip/rar'
      )
      return false
    }
    if (!isLt20M) {
      showMsgError('文件大小不能超过 20MB')
      return false
    }
    const safeFile = formatFileName(file, false)
    return Promise.resolve(safeFile)
  }

  const onFileExceed = () => {
    showMsgError('最多只能上传 4 个文件')
  }

  const httpRequest = async (option) => {
    try {
      const originalFileName = option.file.name
      const formData = new FormData()
      formData.append('files', option.file)
      const response = await axios.post(uploadUrl, formData, {
        headers,
      })
      const resData = response.data
      if (!resData.IsSuccess) {
        showMsgError(resData.ErrorMessage)
        option.onError(resData.ErrorMessage)
        return
      }
      const fileUrl = resData.Data[0] || ''
      const currIndex = fileList.value.findIndex(
        (item) => item.uid === option.file.uid
      )
      if (currIndex > -1) {
        fileList.value[currIndex].url = fileUrl
        fileList.value[currIndex].name = originalFileName
      }
      const isExist = form.attachs.some((item) => item.url === fileUrl)
      if (fileUrl && !isExist) {
        form.attachs.push({ name: originalFileName, url: fileUrl })
      }
      option.onSuccess(resData)
    } catch (error) {
      showMsgError(
        `文件【${option.file.name}】上传失败:${error.message || '网络错误'}`
      )
      option.onError(error)
    }
  }

  const handleFileError = (err, uploadFile) => {
    showMsgError(`文件【${uploadFile.name}】上传失败,请检查网络后重试`)
  }

  const handleRemoveFile = (file, fileList) => {
    fileList.value = fileList
    if (file.url) {
      form.attachs = form.attachs.filter((item) => item.url !== file.url)
    }
  }

  const onSave = async () => {
    const valid = await refForms.value.validate()
    if (!valid) return

    // 仅校验文件是否全部上传完成
    const isAllFileUploaded = fileList.value.every((item) => item.url)
    if (!isAllFileUploaded) {
      showMsgError('请等待所有文件上传完成后再提交!')
      return
    }


    try {
      submitLoading.value = true

      const res = await FlowNextFlowApi(form)
      const { success, msg } = res.data
      if (success) {
        showMsgOK(msg)
        emit('fetch-data')
        onClose()
      } else {
        showMsgError(msg)
      }
    } catch (e) {
      showMsgError(e.message || '提交失败,请重试')
    } finally {
      submitLoading.value = false
    }
  }

  const showDialog = () => {
    dialogFormVisible.value = true
    submitLoading.value = false
  }

  defineExpose({
    showDialog,
  })
</script>

:action + :headers与:http-request的区别

:action + :headers = Element Plus"自动上传模式"

:http-request = 你"完全接管上传"的手动模式

模式①:【原生自动上传】

javascript 复制代码
<el-upload

  :action="uploadUrl"

  :headers="headers"

  name="files"

/>

完全依赖 el-upload 组件自身实现上传

  • 你只需要配置好 action(接口)、headers(请求头)、name(文件字段名);

  • 选中文件后,组件自动帮你创建 FormData、自动发起 POST 请求、自动携带请求头、自动上传文件

模式②:【自定义手动上传】

javascript 复制代码
<el-upload

  action=""

  :http-request="httpRequest"

/>

http-request 会覆盖 el-upload 的默认请求逻辑

  • :action="uploadUrl" 会直接作废:哪怕你写了地址,组件也不会用,你的代码里写 action="" 空值就行,就是个占位符;

  • :headers="headers" 会直接作废:组件不会自动携带任何请求头,请求头需要你在 httpRequest 函数里手动拼接到 axios 请求中;

  • el-upload 只帮你做「文件选择、文件列表展示、文件数量限制、before-upload 前置校验」这些 UI 层面的事;

  • 所有的上传请求逻辑,全部由你手写的 httpRequest 函数实现:包括创建 FormData、发起 axios 请求、携带请求头、处理成功 / 失败回调;

相关推荐
mldong4 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60615 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角6 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!6 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰7 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi8 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马9 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒10 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端