图片上传原理
前端图片上传,本质只有一件事 :把浏览器里的 File / Blob,用 HTTP 请求发送到后端(或对象存储)
不同实现方式,只是在这 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
前端图片压缩,本质上只做 三件事:
-
降低分辨率(width / height)
-
降低编码质量(JPEG quality)
-
改变编码格式(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 请求、携带请求头、处理成功 / 失败回调;