当前位置: 首页 > news >正文

基于el-upload和vue-cropper实现图片上传裁剪组件

效果展示:

这个组件是基于 elementPlus中的el-upload图片上传组件 以及 vue-cropper插件实现;
        可以设置上传的图片数量;
        可以设置裁剪框的宽和高;

组件代码如下:
<template><el-uploadv-model:file-list="fileList"accept="image/*":action="uploadFileUrl"list-type="picture-card":auto-upload="false":limit="limit":on-change="handleChange":on-preview="handlePictureCardPreview":on-remove="handleRemove"ref="uploadRef":class="{ hide: fileList.length >= limit }"><el-icon><Plus/></el-icon></el-upload><el-dialog v-model="dialogVisible" title="图片预览":close-on-click-modal="true"append-to-body><div class="image-preview-container"><img :src="dialogImageUrl" alt="Preview Image" class="preview-image" /></div></el-dialog><!-- 图片裁剪弹窗 --><el-dialogtitle="图片裁剪"v-model="open"width="60%"append-to-body@opened="modalOpened"@close="closeDialog":close-on-click-modal="false":close-on-press-escape="false"><div class="cropper-container"><div class="cropper-wrapper"><VueCropperref="cropper":img="options.img":output-size="1":output-type="'jpeg'":info="true":full="false":can-move="true":can-move-box="true":original="false":autoCrop="options.autoCrop":autoCropWidth="options.autoCropWidth":autoCropHeight="options.autoCropHeight":fixedBox="options.fixedBox":fixed="true":fixed-number="fixedRatio":center-box="true":info-true="true":max-img-size="3000"mode="contain"@real-time="realTime"v-if="visible"/></div>
<!--            <div class="cropper-preview"><div class="preview-title">预览效果</div><div class="preview-box"><div :style="previews.div" class="preview"><img :src="previews.url" :style="previews.img" /></div></div></div>--></div><template #footer><span class="dialog-footer"><el-button @click="cancelCrop" :disabled="uploading">取消</el-button><el-button type="primary" @click="confirmCrop" :loading="uploading">{{ uploading ? '上传中...' : '确认裁剪' }}</el-button></span></template></el-dialog>
</template><script setup>
import { ref, reactive, watch, onMounted, nextTick, computed } from "vue";
import { getToken } from "@/utils/auth";
import { ElMessage, ElLoading } from 'element-plus';
import request from "@/utils/request";
import { VueCropper } from 'vue-cropper';
import 'vue-cropper/dist/index.css';
import { uploadAvatarApi } from '@/api/system/user';// 定义 props
const props = defineProps({// 初始图片数据数组initialImages: {type: Array,default: () => []},// 最大上传数量limit: {type: Number,default: 1},// 裁剪宽度autoCropWidth: {type: Number,default: 200},// 裁剪高度autoCropHeight: {type: Number,default: 200},// 后端返回的图片文件数组(用于编辑时回显)coverFiles: {type: Array,default: () => []}
});// 定义 emits
const emit = defineEmits(['update:oss-ids', 'change']);const fileList = ref([]);
const headers = ref({ Authorization: "Bearer " + getToken(), loginCategory: '' });
const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + "/resource/oss/upload");
const open = ref(false);
const visible = ref(false);
const uploadRef = ref();
const cropper = ref();
const dialogVisible = ref(false);
const dialogImageUrl = ref('');// 当前选择的文件
const currentFile = ref(null);
// 预览数据
const previews = ref({});
// 上传加载状态
const uploading = ref(false);
// 存储所有上传成功的 ossId
const ossIdList = ref([]);// 裁剪配置
const options = reactive({img: '', // 裁剪图片的地址autoCrop: true, // 是否默认生成截图框autoCropWidth: props.autoCropWidth, // 默认生成截图框宽度autoCropHeight: props.autoCropHeight, // 默认生成截图框高度fixedBox: true, // 固定截图框大小 不允许改变
});// 计算裁剪框的宽高比例
const fixedRatio = computed(() => {const width = props.autoCropWidth || 200;const height = props.autoCropHeight || 200;// console.log('计算裁剪比例:', { width, height, ratio: [width, height] });return [width, height];
});// 文件状态改变时的钩子,添加文件、上传成功和上传失败时都会被调用
const handleChange = (file, fileList) => {// console.log('file:', file, 'fileList:', fileList);// 检查文件是否存在if (!file || !file.raw) {ElMessage.error('文件无效');return;}// 验证文件类型const isImage = file.raw.type.indexOf('image/') === 0;if (!isImage) {ElMessage.error('只能上传图片文件!');return;}// 验证文件大小 (5MB)const isLt5M = file.raw.size / 1024 / 1024 < 5;if (!isLt5M) {ElMessage.error('上传图片大小不能超过 5MB!');return;}// 保存当前文件currentFile.value = file;// 读取文件并显示在裁剪器中const reader = new FileReader();reader.onload = (e) => {options.img = e.target.result;open.value = true;};reader.readAsDataURL(file.raw);
};// 预览图片 点击文件列表中已上传的文件时的钩子
const handlePictureCardPreview = (file) => {dialogImageUrl.value = file?.url;dialogVisible.value = true// console.log('预览文件:', file);
};// 移除文件时的钩子
const handleRemove = (file, fileList) => {// console.log('准备移除文件:', file);// 如果文件有 ossId,从 ossIdList 中移除if (file.ossId) {const ossIdIndex = ossIdList.value.indexOf(file.ossId);if (ossIdIndex > -1) {ossIdList.value.splice(ossIdIndex, 1);// console.log('移除 ossId:', file.ossId, '当前 ossIdList:', ossIdList.value);}}// 同步更新 ossIdList,确保只包含当前文件列表中有效的 ossIdsyncOssIdList(fileList.filter(f => f.uid !== file.uid));// 向父组件传递更新后的 ossId 列表emit('update:oss-ids', [...ossIdList.value]);emit('change', {fileList: fileList.filter(f => f.uid !== file.uid),ossIdList: [...ossIdList.value],removedossId: file.ossId,action: 'remove'});
};// 实时预览
const realTime = (data) => {previews.value = data;
};// 打开弹出层结束时的回调
const modalOpened = () => {visible.value = true;// 等待 DOM 更新后调整裁剪器nextTick(() => {const wrapper = document.querySelector('.cropper-wrapper');if (wrapper && cropper.value) {const rect = wrapper.getBoundingClientRect();// console.log('裁剪容器实际尺寸:', {//     width: rect.width,//     height: rect.height,//     expectedCropWidth: options.autoCropWidth,//     expectedCropHeight: options.autoCropHeight// });// 刷新裁剪器以适应容器尺寸setTimeout(() => {if (cropper.value && cropper.value.refresh) {cropper.value.refresh();}}, 100);}});
};// 关闭弹出层开始时的回调
const closeDialog = () => {visible.value = false;options.img = '';currentFile.value = null;
};// 获取当前所有的 ossId 列表
const getossIdList = () => {return [...ossIdList.value];
};// 清空 ossId 列表
const clearossIdList = () => {ossIdList.value = [];fileList.value = [];emit('update:oss-ids', []);emit('change', {fileList: [],ossIdList: [],action: 'clear'});
};// 同步 ossIdList,确保只包含当前文件列表中有效的 ossId
const syncOssIdList = (currentFileList = fileList.value) => {const validOssIds = currentFileList.filter(file => file.ossId && file.status === 'success').map(file => file.ossId);// 移除不再有效的 ossIdossIdList.value = ossIdList.value.filter(ossId => validOssIds.includes(ossId));// 添加新的有效 ossIdvalidOssIds.forEach(ossId => {if (!ossIdList.value.includes(ossId)) {ossIdList.value.push(ossId);}});// console.log('同步后的 ossIdList:', ossIdList.value);
};// 获取当前有效的文件列表(只包含上传成功的文件)
const getValidFileList = () => {return fileList.value.filter(file => file.status === 'success' && file.ossId);
};// 初始化组件数据
const initializeComponent = () => {// 确保裁剪配置使用最新的 props 值options.autoCropWidth = props.autoCropWidth;options.autoCropHeight = props.autoCropHeight;// console.log('初始化裁剪配置:', {//     autoCropWidth: options.autoCropWidth,//     autoCropHeight: options.autoCropHeight// });if (props.coverFiles && props.coverFiles.length > 0) {// console.log('初始化封面图片数据:', props.coverFiles);// 清空现有数据fileList.value = [];ossIdList.value = [];// 处理后端返回的图片数据props.coverFiles.forEach((file, index) => {if (file && file.ossId && file.url) {// 构建文件对象const fileItem = {uid: `init-${index}-${Date.now()}`,name: file.fileName || `image-${index + 1}.jpg`,status: 'success',url: file.url,ossId: file.ossId,response: {ossId: file.ossId,url: file.url}};fileList.value.push(fileItem);// 添加到 ossIdListif (!ossIdList.value.includes(file.ossId)) {ossIdList.value.push(file.ossId);}}});// console.log('初始化完成 - fileList:', fileList.value);// console.log('初始化完成 - ossIdList:', ossIdList.value);// 通知父组件初始数据nextTick(() => {emit('update:oss-ids', [...ossIdList.value]);emit('change', {fileList: [...fileList.value],ossIdList: [...ossIdList.value],action: 'init'});});}
};// 监听 coverFiles 变化
watch(() => props.coverFiles, (newFiles) => {// console.log('coverFiles 数据变化:', newFiles);initializeComponent();
}, {immediate: true,deep: true
});// 组件挂载时初始化
onMounted(() => {initializeComponent();
});// 重置组件数据
const resetComponent = () => {fileList.value = [];ossIdList.value = [];emit('update:oss-ids', []);emit('change', {fileList: [],ossIdList: [],action: 'reset'});
};// 暴露方法给父组件使用
defineExpose({getossIdList,clearossIdList,syncOssIdList,getValidFileList,resetComponent,initializeComponent,ossIdList: ossIdList.value
});// 取消裁剪
const cancelCrop = () => {open.value = false;// 从文件列表中移除当前文件if (currentFile.value) {const index = fileList.value.findIndex(item => item.uid === currentFile.value?.uid);if (index > -1) {fileList.value.splice(index, 1);// 同步 ossIdListsyncOssIdList();// 通知父组件emit('update:oss-ids', [...ossIdList.value]);emit('change', {fileList: [...fileList.value],ossIdList: [...ossIdList.value],action: 'cancel'});}}
};// 确认裁剪
const confirmCrop = async () => {if (!cropper.value) {ElMessage.error('裁剪器未初始化');return;}if (!currentFile.value) {ElMessage.error('未找到当前文件');return;}if (uploading.value) {return; // 防止重复提交}try {uploading.value = true;// 获取裁剪后的图片const blob = await new Promise((resolve, reject) => {cropper.value.getCropBlob((blob) => {if (blob) {resolve(blob);} else {reject(new Error('获取裁剪图片失败'));}});});// 创建FormData用于上传const formData = new FormData();const croppedFile = new File([blob], currentFile.value.name, {type: blob.type,lastModified: Date.now()});formData.append('file', croppedFile);ElMessage.info('正在上传图片...');// 调用uploadAvatarApi接口上传图片const response = await uploadAvatarApi(formData);if (response && response.code === 200) {// console.log('上传成功:', response);// 上传成功,获取后端返回的图片URL和ossIdconst imageUrl = response.data?.url || response.data?.fileName || response.url;const ossId = response.data?.ossId;if (!imageUrl) {throw new Error('服务器未返回图片URL');}// 存储 ossId(如果存在且不为空)if (ossId && ossId !== null && ossId !== undefined && ossId !== '') {// 检查是否已存在相同的 ossId,避免重复添加if (!ossIdList.value.includes(ossId)) {ossIdList.value.push(ossId);// console.log('添加 ossId:', ossId, '当前 ossIdList:', ossIdList.value);}} else {// console.warn('后端返回的 ossId 为空或无效:', ossId);}// 更新文件列表中的文件,将后端返回的URL回显到el-upload组件const fileIndex = fileList.value.findIndex(item => item.uid === currentFile.value?.uid);if (fileIndex > -1) {// 更新现有文件fileList.value[fileIndex] = {...fileList.value[fileIndex],status: 'success',url: imageUrl,response: response.data,raw: croppedFile,ossId: ossId // 存储 ossId 到文件对象中};} else {// 添加新文件到列表fileList.value.push({uid: currentFile.value.uid,name: currentFile.value.name,status: 'success',url: imageUrl,response: response.data,raw: croppedFile,ossId: ossId // 存储 ossId 到文件对象中});}// 同步 ossIdList,确保数据一致性syncOssIdList();// 向父组件传递更新后的 ossId 列表emit('update:oss-ids', [...ossIdList.value]);emit('change', {fileList: [...fileList.value],ossIdList: [...ossIdList.value],currentossId: ossId,currentFile: {uid: currentFile.value.uid,name: currentFile.value.name,url: imageUrl,ossId: ossId},action: 'upload'});ElMessage.success('图片上传成功!');// 关闭裁剪弹窗open.value = false;} else {// 处理业务错误const errorMsg = response?.msg || response?.message || '上传失败';throw new Error(errorMsg);}} catch (error) {// console.error('上传图片失败:', error);// 错误处理let errorMessage = '上传失败';if (error.message) {errorMessage = error.message;} else if (error.response?.data?.msg) {errorMessage = error.response.data.msg;} else if (error.response?.data?.message) {errorMessage = error.response.data.message;}ElMessage.error(errorMessage);// 更新文件状态为失败if (currentFile.value) {const fileIndex = fileList.value.findIndex(item => item.uid === currentFile.value?.uid);if (fileIndex > -1) {fileList.value[fileIndex].status = 'fail';}}} finally {uploading.value = false;}
};
</script><style scoped>
.cropper-container {display: flex;gap: 20px;height: 500px;min-height: 400px;
}.cropper-wrapper {flex: 1;height: 100%;min-width: 400px;position: relative;
}/* 确保 VueCropper 正确适应容器 */
.cropper-wrapper :deep(.vue-cropper) {width: 100% !important;height: 100% !important;
}/* 调试样式 - 可以临时添加边框查看容器边界 */
.cropper-container {/* border: 2px solid red; */
}.cropper-wrapper {/* border: 2px solid blue; */
}.cropper-preview {width: 200px;display: flex;flex-direction: column;
}.preview-title {font-size: 14px;font-weight: bold;margin-bottom: 10px;text-align: center;
}.preview-box {flex: 1;display: flex;align-items: center;justify-content: center;border: 1px solid #dcdfe6;border-radius: 4px;overflow: hidden;
}.preview {overflow: hidden;
}.dialog-footer {display: flex;justify-content: flex-end;gap: 10px;
}
:deep(.el-upload--picture-card i) {transform: translate(-50%, -50%);top: 50%;left: 50%;
}
/* 隐藏上传按钮 */
.hide :deep(.el-upload--picture-card) {display: none;
}/* 图片预览样式 */
.image-preview-container {display: flex;justify-content: center;align-items: center;min-height: 300px;max-height: 70vh;overflow: hidden;
}.preview-image {max-width: 100%;max-height: 70vh;width: auto;height: auto;object-fit: contain;border-radius: 8px;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
关于编辑时回显图片:
// 假设从后端获取到的图片数据
const imageData = [{ossId: '123456',url: 'https://example.com/image.jpg',fileName: 'cover.jpg'}
];// 将数据赋值给coverFiles prop
coverListSmallFiles.value = imageData;
父组件中完整使用案例:
<template><el-form ref="formRef" :model="form"><!-- 封面图片上传 --><el-form-item label="封面"><userAvatar @update:oss-ids="handleOssidsUpdate" @change="handleFileChange" :cover-files="coverListSmallFiles":limit="1":autoCropWidth="180" :autoCropHeight="180"ref="avatarRef" /></el-form-item><!-- 详情图片上传 --><el-form-item label="详情图片"><userAvatar @update:oss-ids="handleDetailOssidsUpdate" @change="handleDetailFileChange" :cover-files="coverListBigFiles":limit="1":autoCropWidth="750" :autoCropHeight="300"ref="detailAvatarRef" /></el-form-item><!-- 相册图片上传 --><el-form-item label="相册图片"><image-upload v-model="form.fileIdData" :limit="4" :isShowTip="false"></image-upload></el-form-item></el-form>
</template><script setup>
import { ref, nextTick } from 'vue';
import userAvatar from "./userAvatar2";const avatarRef = ref(null);
const detailAvatarRef = ref(null);
const coverListSmallFiles = ref([]);  // 封面图片回显数据
const coverListBigFiles = ref([]);    // 详情图片回显数据const form = ref({coverListSmall: undefined,coverListBig: undefined,fileIdData: undefined
});// 处理封面图片ossId更新
const handleOssidsUpdate = (ossids) => {if (ossids && ossids.length > 0) {form.value.coverListSmall = ossids.join(',');} else {form.value.coverListSmall = undefined;}
};// 处理封面图片文件变更
const handleFileChange = (data) => {// 根据操作类型处理数据switch (data.action) {case 'upload':console.log('新上传文件:', data.currentFile);break;case 'remove':console.log('移除文件 ossId:', data.removedossId);break;case 'init':console.log('初始化完成,文件列表:', data.fileList);break;case 'clear':console.log('清空所有文件');break;}// 更新表单数据if (data.ossIdList && data.ossIdList.length > 0) {form.value.coverListSmall = data.ossIdList.join(',');} else {form.value.coverListSmall = undefined;}
};// 处理详情图片ossId更新
const handleDetailOssidsUpdate = (ossids) => {if (ossids && ossids.length > 0) {form.value.coverListBig = ossids.join(',');} else {form.value.coverListBig = undefined;}
};// 处理详情图片文件变更
const handleDetailFileChange = (data) => {// 根据操作类型处理数据switch (data.action) {case 'upload':console.log('新上传详情图片:', data.currentFile);break;case 'remove':console.log('移除详情图片 ossId:', data.removedossId);break;case 'init':console.log('详情图片初始化完成,文件列表:', data.fileList);break;case 'clear':console.log('清空所有详情图片');break;}// 更新表单数据if (data.ossIdList && data.ossIdList.length > 0) {form.value.coverListBig = data.ossIdList.join(',');} else {form.value.coverListBig = undefined;}
};// 表单提交
const onSubmit = () => {// 获取当前 userAvatar 组件中的 ossId 列表const currentOssIds = avatarRef.value ? avatarRef.value.getossIdList() : [];const coverListSmallValue = currentOssIds.length > 0 ? currentOssIds.join(',') : form.value.coverListSmall;// 获取当前详情图片 userAvatar 组件中的 ossId 列表const currentDetailOssIds = detailAvatarRef.value ? detailAvatarRef.value.getossIdList() : [];const coverListBigValue = currentDetailOssIds.length > 0 ? currentDetailOssIds.join(',') : form.value.coverListBig;const params = {coverListSmall: coverListSmallValue,coverListBig: coverListBigValue,fileId: form.value.fileIdData && Array.isArray(form.value.fileIdData) && form.value.fileIdData.length > 0 ? form.value.fileIdData.map(item => item.ossId).join(',') : '',// 其他表单数据...};console.log('提交参数:', params);// 提交数据// submitData(params);
};// 重置表单
const resetForm = () => {form.value = {coverListSmall: undefined,coverListBig: undefined,fileIdData: undefined};// 重置 userAvatar 组件if (avatarRef.value && avatarRef.value.resetComponent) {avatarRef.value.resetComponent();}// 重置详情图片 userAvatar 组件if (detailAvatarRef.value && detailAvatarRef.value.resetComponent) {detailAvatarRef.value.resetComponent();}
};
</script>

        


文章转载自:

http://802cK2Hc.dyyhw.cn
http://HzfIXvcL.dyyhw.cn
http://ld15iAkl.dyyhw.cn
http://ZzJkFCDM.dyyhw.cn
http://onz1xXbH.dyyhw.cn
http://RUbMLLci.dyyhw.cn
http://AHJe4clC.dyyhw.cn
http://mIQzyNLc.dyyhw.cn
http://bi6Ifj2z.dyyhw.cn
http://ryP6vnVw.dyyhw.cn
http://8ItvJxND.dyyhw.cn
http://CJbCTGby.dyyhw.cn
http://O8LrhcJ4.dyyhw.cn
http://Eal1BJf2.dyyhw.cn
http://m0Kbz4Wj.dyyhw.cn
http://yQuzo7OE.dyyhw.cn
http://Yrqqx4FJ.dyyhw.cn
http://mSKUo7Wp.dyyhw.cn
http://OOtrbmc4.dyyhw.cn
http://zcAl98ia.dyyhw.cn
http://VCQdnvHA.dyyhw.cn
http://t2qbsDzP.dyyhw.cn
http://WbQDe3cD.dyyhw.cn
http://aN8E2xlS.dyyhw.cn
http://ShquchX0.dyyhw.cn
http://Du9Uv3ti.dyyhw.cn
http://hnbRyeJ2.dyyhw.cn
http://jlSosIVY.dyyhw.cn
http://8fLpoJWi.dyyhw.cn
http://At3ey93Y.dyyhw.cn
http://www.dtcms.com/a/388540.html

相关文章:

  • Kettle时间戳转换为日期格式处理方式
  • go.js Panel中文API
  • 加密货币中的MEV是什么
  • 【Linux学习笔记】线程概念与控制(一)
  • Linux笔记---非阻塞IO与多路复用
  • 生物信息学中的 AI Agent: Codex 初探
  • 贪心算法应用:埃及分数问题详解
  • 力扣hot100刷题day1
  • 什么是跨站脚本攻击
  • 团队对 DevOps 理解不统一会带来哪些问题
  • I²C 总线通信原理与时序
  • C#关键字record介绍
  • 试验台铁地板的设计与应用
  • 原子操作:多线程编程
  • 项目:寻虫记日志系统(三)
  • 在Arduino上模拟和电子I/O工作
  • Windows 命令行:相对路径
  • 线程、进程、协程
  • Java/注解Annotation/反射/元数据
  • C++学习:哈希表的底层思路及其实现
  • 机器学习python库-Gradio
  • 创作一个简单的编程语言,首先生成custom_arc_lexer.g4文件
  • 湖北燃气瓶装送气工证考哪些科目?
  • MySQL死锁回滚导致数据丢失,如何用备份完美恢复?
  • Zustand入门及使用教程(二--更新状态)
  • Matplotlib统计图:绘制精美的直方图、条形图与箱线图
  • 在el-table-column上过滤数据,进行格式化处理
  • 记一次golang结合前端的axios、uniapp进行预签名分片上传遇到403签名错误踩坑
  • 十一章 无界面压测
  • 多色印刷机的高精度同步控制:EtherCAT与EtherNet/IP的集成应用