【JS】自定义上传组件,文件上传与回显,自定义进度条显示(基于element-plus与axios)

自定义上传组件

🍅一、需求👉二、处理思路1. 上传组件(自定义接口、自定义配置、实时进度)实现逻辑效果展示代码实现表单触发弹窗上传组件代码实现

2. 回显组件(通过文件id回显文件)效果展示
3. axios封装
🌟三、效果展示💡四、思路总结

🍅一、需求

公司是做B 端产品的,有sass平台也有源码交付的项目,最近开发了很多小系统,这里记录一下开发遇到的问题。

需要定制一个上传组件,要求可以自定义文件存储路径,自定义可上传的文件类型,限制文件大小等,并且提供基础的文件回显功能,例如上传一张图片到服务器指定目录的
/file
文件夹下面,并且实现可以查看预览的功能。


仅做基础展示,具体内容大家可以自行优化

👉二、处理思路

前端: 使用
element-plus
上传组件封装一个通用的上传组件,还有一个通用的通过文件id回显文件的组件。
后端: 然后需要后端提供两个接口,接口一实现通用的上传功能,接口二是通过文件的id查询文件的详细信息(包含文件的预览路径等信息),实现文件的回显预览。

1. 上传组件(自定义接口、自定义配置、实时进度)

实现逻辑

在常规表单页面使用自定义样式定义上传模块的样式(绑定有回显组件),触发点击事件打开通用上传的弹窗

效果展示

【JS】自定义上传组件,文件上传与回显,自定义进度条显示(基于element-plus与axios)

【JS】自定义上传组件,文件上传与回显,自定义进度条显示(基于element-plus与axios)

代码实现

表单触发弹窗

<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(',');
};
上传组件代码实现

【JS】自定义上传组件,文件上传与回显,自定义进度条显示(基于element-plus与axios)

其中
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>

效果展示

【JS】自定义上传组件,文件上传与回显,自定义进度条显示(基于element-plus与axios)

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;

🌟三、效果展示

【JS】自定义上传组件,文件上传与回显,自定义进度条显示(基于element-plus与axios)

上传组件

💡四、思路总结

时间紧任务重,有个好的思路就行,希望对你有点帮助

© 版权声明

相关文章

暂无评论

none
暂无评论...