自定义上传组件
🍅一、需求👉二、处理思路1. 上传组件(自定义接口、自定义配置、实时进度)实现逻辑效果展示代码实现表单触发弹窗上传组件代码实现
2. 回显组件(通过文件id回显文件)效果展示
3. axios封装
🌟三、效果展示💡四、思路总结
🍅一、需求
公司是做B 端产品的,有sass平台也有源码交付的项目,最近开发了很多小系统,这里记录一下开发遇到的问题。
需要定制一个上传组件,要求可以自定义文件存储路径,自定义可上传的文件类型,限制文件大小等,并且提供基础的文件回显功能,例如上传一张图片到服务器指定目录的文件夹下面,并且实现可以查看预览的功能。
/file
仅做基础展示,具体内容大家可以自行优化
👉二、处理思路
前端: 使用上传组件封装一个通用的上传组件,还有一个通用的通过文件id回显文件的组件。
element-plus
后端: 然后需要后端提供两个接口,接口一实现通用的上传功能,接口二是通过文件的id查询文件的详细信息(包含文件的预览路径等信息),实现文件的回显预览。
1. 上传组件(自定义接口、自定义配置、实时进度)
实现逻辑
在常规表单页面使用自定义样式定义上传模块的样式(绑定有回显组件),触发点击事件打开通用上传的弹窗
效果展示


代码实现
表单触发弹窗
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="附件" prop="photoUrls" :class="{ 'is-changed': state.changedFields.includes('photoUrls') }">
<div class="custom-upload-box" @click="openAttachmentDialog">
<div class="tip">
<img src="../../../assets/images/common/upload-file.svg" />
<div class="el-upload__text">点击上传</div>
</div>
</div>
<!-- 回显组件 -->
<EchoFileList
:fileIdStr="state.ruleForm.photoUrls"
:type="'isAdd'"
@on-file-delete="(res: any) => (state.ruleForm.photoUrls = res)"
></EchoFileList>
</el-form-item>
</el-col>
// 上传文件配置
const uploadConfig = ref({
filePath: '/filling', // 自定义的文件上传路径(前面需要加斜杠)
fileLimit: 30, // 最大文件上传数
fileSize: 100, // 单个文件最大Mb
// fileType: [], // 文件类型
});
// 打开附件弹窗
const openAttachmentDialog = () => {
attachmentDialogRef.value.openUploadDialog(uploadConfig.value);
};
// 当附件弹窗关闭时
const onAttachmentDialogClose = (fileList: any) => {
let oldArr = state.ruleForm.photoUrls?.split(',') || [];
let newArr = fileList.map((fileItem: any) => {
return fileItem.id;
});
state.ruleForm.photoUrls = [...new Set([...oldArr, ...newArr])].filter(Boolean).join(',');
};
上传组件代码实现

其中函数为主要上传逻辑,包含处理自定义的接口进度处理
handleUploadFile
index.vue
<!--
* @Description: 附件弹窗:可上传附件与预览附件
* @Date: 2023-11-29 14:45:52
-->
<template>
<el-dialog align-center draggable :close-on-click-modal="false" title="附件上传" v-model="state.dialog.show" :destroy-on-close="true" width="40%">
<el-upload
style="width: 100%"
action="#"
class="upload-box"
:drag="true"
:multiple="true"
:show-file-list="true"
:limit="state.uploadConfig.fileLimit"
:accept="state.uploadConfig.fileType.join(',')"
v-model:file-list="state.fileList"
:on-progress="handleProgress"
:on-exceed="handleExceed"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-preview="handlePreview"
:on-remove="handleRemove"
:before-remove="beforeRemove"
:before-upload="beforeUploadFile"
:http-request="(options: any) => handleUploadFile(options)"
>
<slot name="empty">
<el-icon class="el-icon--upload">
<ele-UploadFilled />
</el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</slot>
<template #tip>
<slot name="tip">
<div class="el-upload__tip">
请上传标准格式文件: 单个文件最大{{ state.uploadConfig.fileSize }}MB, 最多上传 {{ state.uploadConfig.fileLimit }}个
</div>
</slot>
</template>
</el-upload>
<!-- <ElProgress v-if="state.isUploading" :percentage="uploadPercent" :status="uploadPercent === 100 ? 'success' : ''" class="mt-4" /> -->
<template #footer>
<span class="dialog-footer">
<el-button @click="closeDialog">关 闭</el-button>
<el-button v-if="state.isUploading" type="warning" loading>上传中</el-button>
<el-button v-else type="primary" @click="onSubmit">保 存</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AttachmentDialog">
import { ref, nextTick, reactive } from 'vue';
import { ElMessage, ElMessageBox, ElNotification, UploadFile, UploadFiles, UploadRawFile, UploadRequestOptions, UploadUserFile } from 'element-plus';
import { useUploadFileApi } from '/@/api/uploadFile';
const emit = defineEmits(['onUploadSubmit', 'onUploadSuccess']);
const baseURL = ref(import.meta.env.VITE_BASE_URL); // 服务器地址
const state: any = reactive({
// 弹窗配置
dialog: {
show: false,
type: '',
},
// 上传配置
uploadConfig: {
filePath: '/tempFile', // 自定义的文件上传路径(前面需要加斜杠)
fileLimit: 2, // 最大文件上传数
fileSize: 30, // 单个文件最大Mb
// 上传文件类型
fileType: [
'.doc',
'.docx',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pdf',
'.xls',
'.xlsx',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.svg',
],
uploadApi: useUploadFileApi().uploadFile, // importApi?: (params: any) => Promise<any>; // 导入的Api
},
// 上传的文件列表
fileList: [],
// 是否正在上传中
isUploading: false,
});
const uploadPercent = ref(0); // 上传进度
// 传入的对象是引用地址,可以同步修改源对象
const openUploadDialog = (config?: object) => {
// 传入外部配置,进行合并配置
if (config) {
state.uploadConfig = { ...state.uploadConfig, ...config };
}
state.dialog.show = true;
};
const closeUploadDialog = () => {
state.dialog.show = false;
emit('onUploadSubmit', state.fileList);
};
// 文件上传
const handleUploadFile = async (param: UploadRequestOptions) => {
state.isUploading = true;
let formData = new FormData();
formData.append('multipartFile', param.file);
formData.append('filePath', state.uploadConfig.filePath); // 自定义的文件上传路径
// 1. 定义 axios 配置对象,包含 onUploadProgress
const config = {
// 关键步骤:使用 param.onProgress 来触发 el-upload 的进度事件
onUploadProgress: (progressEvent: any) => {
// progressEvent 由 axios 提供
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
// 2. 调用 param.onProgress() 通知 Element Plus 更新进度条
param.onProgress({ percent } as any);
},
};
// 3. 将 formData 和 config 传递给封装的 API
const { data } = await state.uploadConfig.uploadApi!(formData, config);
// 4. 通知 Element Plus 上传成功
param.onSuccess(data);
// 5. 后续处理文件列表和状态
state.fileList = state.fileList.filter((file: any) => !file?.raw);
let result = {
...data[0],
name: data[0].name + '.' + data[0].type,
url: `${baseURL.value}${data[0].rootPath}${data[0].path}/${data[0].name}.${data[0].type}`,
};
state.fileList.push(result);
console.log(state.fileList);
state.isUploading = false;
};
// 上传进度
const handleProgress = (event: any) => {
uploadPercent.value = Math.round(event.percent);
};
/**
* @description 文件上传之前判断
* @param file 上传的文件
* */
const beforeUploadFile = (file: UploadRawFile) => {
const within = file.size / 1024 / 1024 < state.uploadConfig.fileSize!;
// 如果超出范围则弹出提示
if (!within) {
setTimeout(() => {
ElNotification({
title: '温馨提示',
message: `上传文件大小不能超过 ${state.uploadConfig.fileSize}MB!`,
type: 'warning',
});
}, 0);
}
return within;
};
const handlePreview = (uploadFile: UploadFile) => {
window.open(uploadFile.url, '_blank');
};
const handleExceed = (files: File[], uploadFiles: UploadUserFile[]) => {
ElMessage.warning(
`最多上传${state.uploadConfig.fileLimit}个文件,
你此次选择了 ${files.length} 个文件,
一共 ${files.length + uploadFiles.length} 个文件`
);
};
const handleRemove = (uploadFile: UploadFile, uploadFiles: UploadFiles) => {
console.log(uploadFile, uploadFiles);
};
const beforeRemove = (uploadFile: UploadFile) => {
return ElMessageBox.confirm(`确认删除 ${uploadFile.name} ?`).then(
() => true,
() => false
);
};
// 上传成功提示
const handleUploadSuccess = (response: any, file: any) => {
state.isUploading = false;
uploadPercent.value = 100;
emit('onUploadSuccess', response);
ElNotification({
title: '温馨提示',
message: `批量添加成功!`,
type: 'success',
});
};
// 上传错误提示
const handleUploadError = () => {
ElNotification({
title: '温馨提示',
message: `批量添加失败,请您重新上传!`,
type: 'error',
});
};
// 关闭弹窗
const closeDialog = () => {
state.dialog.show = false;
nextTick(() => {
state.fileList = [];
});
};
// 提交
const onSubmit = () => {
emit('onUploadSubmit', state.fileList);
closeDialog();
};
// 暴露给父组件使用
defineExpose({
openUploadDialog,
closeUploadDialog,
});
</script>
<style scoped lang="scss">
.el-upload__tip {
color: red;
}
</style>
2. 回显组件(通过文件id回显文件)
EchoFileList.vue
<!--
* @Description: 文件回显列表
* @Date: 2023-11-30 09:35:28
-->
<template>
<div class="file-list-box">
<template v-if="fileList?.length">
<div class="file-item" title="单击查看" v-for="item in fileList" :key="item.id" @click="onFileClick(item)">
{{ item.name }}
<el-button v-if="props.type != 'isDetail'" class="file-item-delete" type="danger" circle plain size="small" @click.stop="onFileDelete(item)">
<el-icon><ele-Close /></el-icon>
</el-button>
</div>
</template>
<div v-else class="empty">未上传附件</div>
</div>
</template>
<script setup lang="ts" name="EchoFileList">
import { ref, watch } from 'vue';
import { useUploadFileApi } from '/@/api/uploadFile';
const baseURL = ref(import.meta.env.VITE_BASE_URL); // 服务器地址
const fileList: any = ref([]);
const props = defineProps({
// 文件id逗号拼接字符串
fileIdStr: {
type: String,
default: '',
},
// 组件类型:新增isAdd、修改isEdit、详情isDetail
type: {
type: String,
default: 'isAdd',
},
});
const emit = defineEmits(['onFileClick', 'onFileDelete']);
watch(
() => props.fileIdStr,
async (fileIdStr) => {
if (fileIdStr) {
// 通过文件id列表查询文件详情列表
const { data } = await useUploadFileApi().queryFileDetail(fileIdStr.split(','));
fileList.value = data.map((fileItem: any) => {
return {
...fileItem,
name: fileItem.name + '.' + fileItem.type,
url: `${baseURL.value}${fileItem.rootPath}/${fileItem.path}/${fileItem.name}.${fileItem.type}`,
};
});
} else {
fileList.value = [];
}
},
{ immediate: true }
);
// 查看附件详情
const onFileClick = (fileItem: any) => {
window.open(fileItem.url);
emit('onFileClick');
};
// 删除文件
const onFileDelete = (item: any) => {
fileList.value = fileList.value.filter((fileItem: any) => {
return fileItem.id !== item.id;
});
let fileIdStr = fileList.value
.map((fileItem: any) => {
return fileItem.id;
})
.join(',');
emit('onFileDelete', fileIdStr);
return fileIdStr;
};
</script>
<style lang="scss" scoped>
.file-list-box {
width: 100%;
display: flex;
flex-direction: column;
.empty {
width: 100%;
min-width: 40px;
border-radius: 4px 4px 4px 4px;
border: 1px dashed #dcdfe6;
padding-left: 10px;
color: #7f92ff;
}
.file-item {
position: relative;
display: flex;
align-items: center;
background-color: #b3ddef2b;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 5px 5px 5px 10px;
font-size: 16px;
cursor: pointer;
margin-top: 5px;
&:hover {
background-color: #99cbe152;
.file-item-delete {
display: block;
}
}
.file-item-delete {
display: none;
position: absolute;
right: 18px;
}
}
}
</style>
效果展示

3. axios封装
接口调用
/*
* @Description: 文件管理api接口集合
* @Date: 2023-07-27 14:20:40
* @LastEditTime: 2025-11-27 18:20:21
*/
import axiosRequest from '/@/utils/request';
const baseURL = import.meta.env.VITE_API_URL;
/**
* 文件管理api接口集合
* @method uploadFile 文件上传
* @method queryFileDetail 通过文件id查询文件详情
* @method downloadFile 文件下载(为上传格式)
* @method downloadFileZip 文件下载(统一为zip压缩包)
* @method getFile 图片加载
*/
export function useUploadFileApi() {
return {
// 文件上传
uploadFile: async (params: object, config: any) => {
return await axiosRequest({
url: '/api/file/uploadFile',
method: 'POST',
data: params,
headers: { 'content-type': 'multipart/form-data' },
// 允许 config 中包含的 onUploadProgress 等配置项生效(onUploadProgress 是 axios 库提供的一个标准配置选项)
...config
});
},
// 通过文件id查询文件详情
queryFileDetail: async (fileIdList: string[]) => {
return await axiosRequest({
url: '/api/file/queryFileDetail',
method: 'POST',
data: { fileIdList: fileIdList },
});
},
// 文件下载(为上传格式)
downloadFile: async (params: object) => {
return await axiosRequest({
url: '/api/file/downloadFile',
method: 'POST',
params,
responseType: 'blob',
headers: { staticResource: true },
});
},
// 文件下载(统一为zip压缩包)
downloadFileZip: async (params: object) => {
return await axiosRequest({
url: '/api/file/downloadFileZip',
method: 'POST',
params,
responseType: 'blob',
headers: { staticResource: true },
});
},
// 图片加载
getFile: (id: any) => {
const tempUrl = baseURL + `/api/file/getFile?id=${id}`;
return tempUrl;
},
}
}
axios模块request.ts
// axios模块request.ts
import axios, { AxiosInstance } from 'axios';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Session } from '/@/utils/storage';
import qs from 'qs';
import { useMyWork } from '/@/stores/myWork';
import { debounce } from '/@/utils';
// 配置新建一个 axios 实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 50000,
// headers: { 'Content-Type': 'application/json' },
// paramsSerializer: {
// serialize(params) {
// return qs.stringify(params, { allowDots: true });
// },
// },
});
// 添加请求拦截器
service.interceptors.request.use(
async (config) => {
// 设置请求头
if (!config.headers["content-type"] && !config.headers["Content-Type"]) { // 如果没有设置请求头
//如果是需要转码的post
if (config.method === 'post' && config.headers.stringify) {
config.headers["content-type"] = "application/x-www-form-urlencoded"; // post 请求
config.data = qs.stringify(config.data); // 序列化,比如表单数据
//config.data = JSON.stringify(config.data); // 序列化,比如表单数据
} else {
config.headers["content-type"] = "application/json"; // 默认类型json
}
}
// 在发送请求之前做些什么 token
if (Session.get('token')) {
// config.headers!['Authorization'] = `${Session.get('token')}`;
config.headers!['token'] = `${Session.get('token')}`;
// 通知查询: 登录后才查询通知列表
if (!config.headers?.noticeFlag) {
debounce(async function () {
await useMyWork().getNotificationList()
}, 2000);
}
}
// 添加请求时间戳
config.headers['request-time'] = new Date().getTime();
return config;
},
(error) => {
// 对请求错误做些什么
return Promise.reject(error);
}
);
// 添加响应拦截器
service.interceptors.response.use(
(response) => {
// 对响应数据做点什么
const res = response.data;
const config = response.config;
// 处理自己的业务逻辑,比如判断 token 是否过期等等
if (res.code !== 200 && !config.headers?.staticResource) {
if (res.code === 401 || res.code === 4001) {
// token过期或者账号已在别处登录
Session.clear(); // 清除浏览器全部临时缓存
window.localStorage.clear();
// window.location.href = '/'; // 去登录页
ElMessageBox.alert(res.msg || '你已被登出,请重新登录', '提示', {})
.then(() => {
location.reload();
})
.catch(() => { });
} else {
ElMessage.error(res.msg); //弹出错误提示
}
return Promise.reject(response.data);
} else {
return response.data;
}
},
(error) => {
// 对响应错误做点什么
if (error.message.indexOf('timeout') != -1) {
ElMessage.error('网络超时');
} else if (error.message == 'Network Error') {
ElMessage.error('网络连接错误');
} else {
if (error.response.data) ElMessage.error(error.response.statusText);
else ElMessage.error('接口路径找不到');
}
return Promise.reject(error);
}
);
// 导出 axios 实例
export default service;
🌟三、效果展示

上传组件
💡四、思路总结
时间紧任务重,有个好的思路就行,希望对你有点帮助
© 版权声明
文章版权归作者所有,未经允许请勿转载。
相关文章
暂无评论...