图片、文件上传

图片上传原理

前端图片上传,本质只有一件事 :把浏览器里的 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 请求、携带请求头、处理成功 / 失败回调;

相关推荐
Mr Xu_2 小时前
Vue3 + Element Plus 实现点击导航平滑滚动到页面指定位置
前端·javascript·vue.js
小王努力学编程2 小时前
LangChain——AI应用开发框架(核心组件1)
linux·服务器·前端·数据库·c++·人工智能·langchain
pas1362 小时前
35-mini-vue 实现组件更新功能
前端·javascript·vue.js
前端达人2 小时前
为什么聪明的工程师都在用TypeScript写AI辅助代码?
前端·javascript·人工智能·typescript·ecmascript
快乐点吧2 小时前
使用 data-属性和 CSS 属性选择器实现状态样式控制
前端·css
EndingCoder3 小时前
属性和参数装饰器
java·linux·前端·ubuntu·typescript
小二·3 小时前
Python Web 开发进阶实战:量子机器学习实验平台 —— 在 Flask + Vue 中集成 Qiskit 构建混合量子-经典 AI 应用
前端·人工智能·python
TTGGGFF3 小时前
控制系统建模仿真(十):实战篇——从工具掌握到工程化落地
前端·javascript·ajax
郝学胜-神的一滴4 小时前
深入解析C/S架构与B/S架构:技术选型与应用实践
c语言·开发语言·前端·javascript·程序人生·架构