基于Cropper与element plus自己封装头像裁剪插件 电脑版发表于:2025/9/1 15:10 [TOC] ### 安装依赖 ``` npm install cropperjs@1.6.2 --save ``` 其他版本用法可能不一样 ### 封装的组件如下 创建:AvatarCropper.vue ``` <template> <div class="avatar-cropper-container"> <!-- 头像显示区域 - 根据showDefaultAvatar控制是否显示 --> <div v-if="showDefaultAvatar" class="avatar-container" @click="triggerFileInput" > <div class="avatar-wrapper"> <div class="avatar-preview"> <img v-if="croppedImage || hasValidImageUrl" :src="croppedImage || getImgUrl(props.modelValue)" class="avatar-image" alt="头像预览" /> <div v-else class="avatar-placeholder"> <el-icon class="upload-icon"><Plus /></el-icon> </div> </div> </div> <div class="upload-text">{{ uploadText }}</div> </div> <input type="file" ref="fileInputRef" accept="image/*" class="hidden-input" @change="handleFileUpload" /> <!-- 裁剪弹窗 --> <el-dialog v-model="showModal" title="裁剪头像" :width="dialogWidth" :before-close="closeModal" append-to-body destroy-on-close > <div class="dialog-content"> <!-- 裁剪区域和预览区域容器 --> <div class="crop-and-preview-container"> <!-- 裁剪区域 --> <div class="crop-area" :style="{ height: cropperHeight }"> <img ref="cropperImageRef" :src="imageSrc" alt="裁剪图片"> </div> <!-- 实时预览区域 --> <div class="preview-area"> <div class="preview-title">实时预览</div> <div class="preview-container"> <!-- 正方形预览 --> <div class="preview-item"> <div class="preview-type">正方形</div> <div class="square-frame"> <img :src="livePreviewUrl" class="preview-image" alt="正方形预览" /> </div> </div> <!-- 圆形预览 --> <div class="preview-item"> <div class="preview-type">圆形</div> <div class="circle-frame"> <img :src="livePreviewUrl" class="preview-image" alt="圆形预览" /> </div> </div> </div> </div> </div> </div> <div class="dialog-footer"> <div class="controls"> <el-button-group> <el-button @click="rotate(-90)">左转</el-button> <el-button @click="rotate(90)">右转</el-button> </el-button-group> <el-button-group> <el-button @click="zoom(0.1)">放大</el-button> <el-button @click="zoom(-0.1)">缩小</el-button> </el-button-group> <el-button @click="resetCrop">重置</el-button> <el-button @click="reselectImage">重新选择</el-button> <el-button type="primary" @click="confirmCrop"> 确定裁剪 </el-button> <el-button @click="closeModal">取消</el-button> </div> </div> </el-dialog> </div> </template> <script setup> import { ref, nextTick, onMounted, defineProps, defineEmits, watch } from 'vue'; import Cropper from 'cropperjs'; import 'cropperjs/dist/cropper.css'; import { ElMessage } from 'element-plus'; import { Plus } from '@element-plus/icons-vue'; import { getImgUrl } from '/@/utils/toolsFunctions'; // 响应式变量声明 const imageSrc = ref(''); const showModal = ref(false); const croppedImage = ref(''); const fileInputRef = ref(null); const cropperImageRef = ref(null); const cropperInstance = ref(null); const livePreviewUrl = ref(''); const uploadInProgress = ref(false); const initialCropData = ref(null); const isReselecting = ref(false); const props = defineProps({ modelValue: { type: [String, Number], default: '' }, // 上传提示文字,默认"点击上传或更换头像" uploadText: { type: String, default: '点击上传或更换头像' }, // 是否显示默认的头像展示区域 showDefaultAvatar: { type: Boolean, default: true }, config: { type: Object, default: () => ({ aspectRatio: 1, viewMode: 1, dragMode: 'move', autoCropArea: 0.7, responsive: true, restore: false, cropWidth: 200, cropHeight: 200 }) }, uploadUrl: { type: String, required: true }, uploadHeaders: { type: Object, default: () => ({}) }, uploadParams: { type: Object, default: () => ({}) }, cropperHeight: { type: String, default: '400px' }, dialogWidth: { type: String, default: '800px', validator: (value) => { return /^\d+(\.\d+)?%$/.test(value) || /^\d+(\.\d+)?px$/.test(value); } }, avatarSize: { type: String, default: '150px' }, previewWidth: { type: String, default: '220px' } }); // 定义事件,包括上传成功的回调 const emit = defineEmits([ 'update:modelValue', 'upload-success', 'upload-error', 'image-saved' // 新增:图片存储成功后的回调 ]); // 检查是否有有效的图片URL const hasValidImageUrl = ref(false); // 初始化处理 onMounted(() => { updateImageUrl(props.modelValue); }); // 更新图片URL状态 const updateImageUrl = (newVal) => { hasValidImageUrl.value = !!newVal; }; // 监听外部值变化 watch(() => props.modelValue, (newVal) => { updateImageUrl(newVal); }); // 外部调用打开裁剪弹窗的方法 const openCropDialog = () => { if (uploadInProgress.value) return; triggerFileInput(); }; // 更新实时预览 const updateLivePreview = () => { if (!cropperInstance.value) return; const canvas = cropperInstance.value.getCroppedCanvas({ width: 200, height: 200, imageSmoothingQuality: 'high' }); livePreviewUrl.value = canvas.toDataURL('image/png'); }; // 初始化裁剪工具 const initializeCropper = () => { if (!imageSrc.value || !cropperImageRef.value) return; nextTick(() => { // 销毁现有实例 if (cropperInstance.value) { cropperInstance.value.destroy(); cropperInstance.value = null; } // 配置裁剪工具 const cropConfig = { ...props.config, aspectRatio: 1, ready() { initialCropData.value = cropperInstance.value.getData(); isReselecting.value = false; updateLivePreview(); }, crop: updateLivePreview, cropend: updateLivePreview }; // 创建新的裁剪实例 cropperInstance.value = new Cropper(cropperImageRef.value, cropConfig); }); }; // 文件上传处理 const handleFileUpload = (e) => { const file = e.target.files[0]; if (!file) { if (isReselecting.value) { isReselecting.value = false; } return; } // 检查文件类型和大小 const isImage = file.type.startsWith('image/'); if (!isImage) { ElMessage.error('请上传图片文件!'); return; } const isLt5M = file.size / 1024 / 1024 < 5; if (!isLt5M) { ElMessage.error('上传图片大小不能超过 5MB!'); return; } isReselecting.value = true; livePreviewUrl.value = ''; const reader = new FileReader(); reader.onload = (event) => { imageSrc.value = event.target.result; showModal.value = true; // 销毁旧实例 if (cropperInstance.value) { cropperInstance.value.destroy(); cropperInstance.value = null; } nextTick(() => { initializeCropper(); }); }; reader.readAsDataURL(file); }; // 触发文件选择 const triggerFileInput = () => { if (uploadInProgress.value) return; fileInputRef.value?.click(); }; // 旋转图片 const rotate = (degrees) => { if (cropperInstance.value) { cropperInstance.value.rotate(degrees); updateLivePreview(); } }; // 缩放图片 const zoom = (ratio) => { if (cropperInstance.value) { cropperInstance.value.zoom(ratio); updateLivePreview(); } }; // 重置裁剪 const resetCrop = () => { if (cropperInstance.value && initialCropData.value) { cropperInstance.value.setData(initialCropData.value); updateLivePreview(); } }; // 重新选择图片 const reselectImage = () => { isReselecting.value = true; fileInputRef.value.value = ''; triggerFileInput(); }; // 关闭弹窗 const closeModal = () => { showModal.value = false; // 清理资源 if (cropperInstance.value) { cropperInstance.value.destroy(); cropperInstance.value = null; } imageSrc.value = ''; livePreviewUrl.value = ''; isReselecting.value = false; }; // 确认裁剪并上传 const confirmCrop = async () => { if (!cropperInstance.value) { ElMessage.error('请先选择图片'); return; } try { uploadInProgress.value = true; const canvas = cropperInstance.value.getCroppedCanvas({ width: props.config.cropWidth || 200, height: props.config.cropHeight || 200, imageSmoothingQuality: 'high' }); canvas.toBlob(async (blob) => { const formData = new FormData(); formData.append('file', blob, `avatar_${Date.now()}.png`); // 添加额外的上传参数 Object.entries(props.uploadParams).forEach(([key, value]) => { formData.append(key, value); }); // 执行上传 const result = await uploadFile(formData); if (result.success) { croppedImage.value = canvas.toDataURL('image/png'); const imageId = result.data?.id || result.id; if (imageId) { emit('update:modelValue', imageId); emit('upload-success', result); // 触发图片存储成功的回调,传递图片ID和完整结果 emit('image-saved', { imageId, result, imageUrl: croppedImage.value }); ElMessage.success('头像上传成功'); } else { ElMessage.error('上传成功但未获取到图片ID'); } } else { emit('upload-error', result); ElMessage.error(result.message || '上传失败'); } closeModal(); }, 'image/png'); } catch (error) { emit('upload-error', error); ElMessage.error('裁剪失败,请重试'); console.error('裁剪失败:', error); } finally { uploadInProgress.value = false; } }; // 执行文件上传 const uploadFile = async (formData) => { try { const response = await fetch(props.uploadUrl, { method: 'POST', headers: props.config.uploadHeaders || props.uploadHeaders, body: formData }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } catch (error) { console.error('上传失败:', error); return { success: false, message: error.message }; } }; console.log(6) // 暴露方法给父组件,允许外部触发 defineExpose({ openCropDialog: openCropDialog }); </script> <style scoped> .hidden-input { display: none; } /* 头像容器样式 */ .avatar-container { display: flex; flex-direction: column; align-items: center; cursor: pointer; } .avatar-wrapper { position: relative; margin-bottom: 10px; } .avatar-preview { width: v-bind(avatarSize); height: v-bind(avatarSize); border-radius: 50%; overflow: hidden; border: 2px dashed #dcdfe6; transition: border-color 0.3s; display: flex; align-items: center; justify-content: center; } .avatar-container:hover .avatar-preview { border-color: #409eff; } .avatar-image { width: 100%; height: 100%; object-fit: cover; display: block; } .avatar-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background-color: #f5f7fa; } .upload-icon { font-size: 32px; color: #8c939d; } .upload-text { color: #606266; font-size: 14px; } /* 弹窗样式 */ .dialog-content { padding: 20px; } /* 裁剪和预览区域容器 */ .crop-and-preview-container { display: flex; gap: 20px; width: 100%; } /* 裁剪区域 */ .crop-area { flex: 1; display: flex; justify-content: center; align-items: center; overflow: hidden; border: 1px solid #eee; position: relative; } .crop-area img { max-height: 100%; object-fit: contain; } /* 预览区域 */ .preview-area { width: v-bind(previewWidth); display: flex; flex-direction: column; } .preview-title { font-size: 16px; font-weight: 500; margin-bottom: 15px; color: #303133; text-align: center; } .preview-container { flex: 1; display: flex; flex-direction: column; align-items: center; } /* 预览项样式 */ .preview-item { margin-bottom: 20px; width: 100%; display: flex; flex-direction: column; align-items: center; } .preview-type { font-size: 14px; color: #606266; margin-bottom: 8px; } /* 正方形预览框 */ .square-frame { width: 160px; height: 160px; border: 3px solid #f0f0f0; overflow: hidden; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } /* 圆形预览框 */ .circle-frame { width: 160px; height: 160px; border-radius: 50%; border: 3px solid #f0f0f0; overflow: hidden; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .preview-image { width: 100%; height: 100%; object-fit: cover; display: block; } .dialog-footer { padding: 15px 20px; border-top: 1px solid #ebeef5; } .controls { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } .el-button-group { margin-right: 15px; } /* 响应式调整 */ @media (max-width: 768px) { .crop-and-preview-container { flex-direction: column; } .preview-area { width: 100%; margin-top: 20px; } .preview-item { margin: 0 auto 20px; } } </style> ``` ### 使用方式如下 ``` <template> <div class="user-info"> <div class="headimgwapper"> <div class="hi-content" @click="openAvatarCrop"> <img :src="userInfo.stuInfo.headUrl" class="headimg" alt="用户头像" /> </div> </div> <AvatarCropper ref="avatarCropperRef" v-model="userAvatar" :show-default-avatar="false" @image-saved="handleImageSaved" :upload-headers="getHeaderToken()" upload-url="/oss/api/SmartFiles/UpLoadFormFile" :avatar-size="'120px'" :cropper-height="'520px'" :dialog-width="'900px'" :upload-params="{ bucketName: 'project-based-learning', filePath: 'admin/', FileType: 1 }" /> </div> </template> <script setup> import AvatarCropper from '/@/components/imageCropper/AvatarCropper.vue' // 给头像裁剪插件绑定的v-model响应式变量可要可不要,回调事件中可以拿到更多的数据 const userAvatar = ref('/oss/api/ImageViewer/bc6e1b67c5f540edbc616f90a69f59b2.jpg') const avatarCropperRef = ref(null); // 打开裁剪弹窗 const openAvatarCrop = () => { avatarCropperRef.value?.openCropDialog(); }; // 处理图片保存成功 const handleImageSaved = (data) => { // 根据逻辑修改图片的回显,裁剪插件本身已经支持回显了,如果需要自己实现也可以加入自己的回显逻辑,把:show-default-avatar设置为false userInfo.value.stuInfo.headUrl = '/oss/api/ImageViewer/' + data.imageId + '.jpg' console.log('图片保存成功', data) // 调用接口保存头像ID到用户信息 saveUserAvatar(data.imageId); } const saveUserAvatar = async (imageId) => { let headUrl = '/oss/api/ImageViewer/' + imageId + '.jpg' const result = await request.post('/xxx/api/UserAccount/UpdateHeadUrl', { HeadUrl: headUrl, }) // 修改一下缓存中的值 // updateUserHeadCache(headUrl) } </script> ``` #### 常用的属性说明 showDefaultAvatar :默认true,控制是否显示默认的头像区域,如果为true裁剪本身会显示头像然后点击这个头像就打开弹窗,如果设置为false就需要自己控制头像的显示以及自己去触发头像裁剪弹窗的打开(通过avatarCropperRef.value?.openCropDialog()打开),上面的例子就是自己打开的弹窗,暴露了 openCropDialog 方法,允许通过父组件触发裁剪弹窗。 upload-url:图片裁剪后上传的存储接口。(插件里边的图片上传逻辑这些可以根据自己的图片上传逻辑微调) #### 常用事件说明 image-saved:在图片上传并存储成功后触发 ``` <AvatarCropper @image-saved="(data) => { console.log('头像保存成功,ID为:', data.imageId); // 在这里调用你的接口保存头像信息 }" /> ```