最终效果
技术要点
图片裁剪
安装依赖 vue-cropper
npm install vue-cropper@next
专用于vue3 项目的图片裁剪,详细使用参考官方文档
页面使用
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
<vue-cropperref="cropper"v-bind="option"@realTime="realTime"
></vue-cropper>
const cropper = ref();
const option = ref({autoCrop: true, // 是否默认生成截图框autoCropHeight: "240px", // 默认生成截图框宽度(默认值:容器的 80%, 可选值:0 ~ max), 真正裁剪出来的图片的宽度为 autoCropHeight * 1.25autoCropWidth: "240px", // 默认生成截图框宽度(默认值:容器的 80%, 可选值:0 ~ max), 真正裁剪出来的图片的宽度为 autoWidth * 1.25canMove: true, // 上传图片是否可以移动canScale: true, // 图片是否允许滚轮缩放centerBox: true, // 截图框是否被限制在图片里面fixed: true, // 是否固定截图框的宽高比例fixedBox: true, // 是否固定截图框大小fixedNumber: [1, 1], // 截图框的宽高比例([ 宽度 , 高度 ])img: "", // 裁剪图片的地址(可选值:url 地址, base64, blob)info: true, // 是否显示裁剪框的宽高信息infoTrue: true, // infoTrue为 true 时裁剪框显示的是预览图片的宽高信息,infoTrue为 false 时裁剪框显示的是裁剪框的宽高信息mode: "contain", // 截图框可拖动时的方向(可选值:contain , cover, 100px, 100% auto)origin: false, // 上传的图片是否按照原始比例渲染outputSize: 1, // 裁剪生成图片的质量(可选值:0.1 ~ 1)outputType: "png", // 裁剪生成图片的格式(可选值:png, jpeg, webp)full: true,
});
const previews = ref<any>({url: "",file: null,
});
// 实时预览
const realTime = () => {cropper.value.getCropBlob((blob: Blob) => {previews.value.url = window.URL.createObjectURL(blob);previews.value.file = blobToFile(blob, imageName.value);});
};
裁剪效果预览
<div class="preview"><img :src="previews.url" /></div>
.preview {width: 150px;height: 150px;margin: 0px auto 20px auto;border-radius: 50%;border: 1px solid #ccc;background-color: #ccc;overflow: hidden;
}
阻止点击冒泡
@click.stop
组件封装 S-avatar.vue
components/SUI/S-avatar.vue
<template><el-uploadclass="avatar-uploader"action="#":show-file-list="false":on-success="handleAvatarSuccess":before-upload="beforeAvatarUpload":accept="imgType":drag="drag":disabled="disabled"><S-msgWin :msg="callbackMessage" :duration="500" /><div v-if="imageUrl" @click.stop class="avatar-container relative group"><el-imageclass="avatar":src="imageUrl"fit="cover":preview-src-list="[imageUrl]"/><divv-if="!disabled"class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"><el-icon:size="30"color="white"class="edit-icon text-white mr-8 cursor-pointer"@click.stop="handleEditAvatar"><Edit /></el-icon><el-popconfirm title="确定删除吗?" @confirm="handleDeleteAvatar"><template #reference><el-icon@click.stop:size="30"color="white"class="delete-icon text-white cursor-pointer"><Delete /></el-icon></template></el-popconfirm></div></div><el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon></el-upload><el-dialog title="修改头像" v-model="editAvatarDialog" width="600"><el-row type="flex" justify="center" class="nowarp"><div class="cropper"><vue-cropperref="cropper"v-bind="option"@realTime="realTime"></vue-cropper></div><div class="previewBox"><div class="preview"><img :src="previews.url" /></div><el-row type="flex" justify="center"><el-uploadaction="#":show-file-list="false":on-success="handleAvatarSuccess":before-upload="beforeAvatarUpload"><el-button size="small" type="primary"> 更换头像 </el-button></el-upload></el-row><br /><el-row><el-button:icon="ZoomIn"circlesize="small"@click="changeScale(1)"></el-button><el-button:icon="ZoomOut"circlesize="small"@click="changeScale(-1)"></el-button><el-button:icon="Download"circlesize="small"@click="downloadPreView"></el-button><el-button:icon="RefreshLeft"circlesize="small"@click="rotateLeft"></el-button><el-button:icon="RefreshRight"circlesize="small"@click="rotateRight"></el-button></el-row></div></el-row><template #footer><div class="dialog-footer"><el-button @click="editAvatarDialog = false">取 消</el-button><el-button type="primary" @click="editAvatarConfirm">确 定</el-button></div></template></el-dialog>
</template><script setup lang="ts">
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
import {ZoomIn,ZoomOut,Download,RefreshLeft,RefreshRight,
} from "@element-plus/icons-vue";
import { ref } from "vue";
import { Plus, Edit, Delete } from "@element-plus/icons-vue";
import type { UploadProps } from "element-plus";const { imgType, drag, disabled, maxImgSize } = defineProps({imgType: {type: String,default: "image/*",},drag: {type: Boolean,default: false,},disabled: {type: Boolean,default: false,},maxImgSize: {type: Number,default: 2,},
});const imageUrl = defineModel<string>();const { uploadImage } = useImageUpload();
const editAvatarDialog = ref(false);
const imageName = ref("");const previews = ref<any>({url: "",file: null,
});
// 响应式变量
const callbackMessage = useCallbackMessage();
const cropper = ref();const option = ref({autoCrop: true, // 是否默认生成截图框autoCropHeight: "240px", // 默认生成截图框宽度(默认值:容器的 80%, 可选值:0 ~ max), 真正裁剪出来的图片的宽度为 autoCropHeight * 1.25autoCropWidth: "240px", // 默认生成截图框宽度(默认值:容器的 80%, 可选值:0 ~ max), 真正裁剪出来的图片的宽度为 autoWidth * 1.25canMove: true, // 上传图片是否可以移动canScale: true, // 图片是否允许滚轮缩放centerBox: true, // 截图框是否被限制在图片里面fixed: true, // 是否固定截图框的宽高比例fixedBox: true, // 是否固定截图框大小fixedNumber: [1, 1], // 截图框的宽高比例([ 宽度 , 高度 ])img: "", // 裁剪图片的地址(可选值:url 地址, base64, blob)info: true, // 是否显示裁剪框的宽高信息infoTrue: true, // infoTrue为 true 时裁剪框显示的是预览图片的宽高信息,infoTrue为 false 时裁剪框显示的是裁剪框的宽高信息mode: "contain", // 截图框可拖动时的方向(可选值:contain , cover, 100px, 100% auto)origin: false, // 上传的图片是否按照原始比例渲染outputSize: 1, // 裁剪生成图片的质量(可选值:0.1 ~ 1)outputType: "png", // 裁剪生成图片的格式(可选值:png, jpeg, webp)full: true,
});const beforeAvatarUpload: UploadProps["beforeUpload"] = (rawFile) => {if (rawFile.size / 1024 / 1024 > maxImgSize) {callbackMessage.value = {show: true,valid: false,content: `图片大小不能超过${maxImgSize}MB!`,};return false;}return true;
};const handleAvatarSuccess: UploadProps["onSuccess"] = (response,uploadFile
) => {// ! 为 TS 的非空断言option.value.img = URL.createObjectURL(uploadFile.raw!);editAvatarDialog.value = true;imageName.value = uploadFile.name;
};// 实时预览
const realTime = () => {cropper.value.getCropBlob((blob: Blob) => {previews.value.url = window.URL.createObjectURL(blob);previews.value.file = blobToFile(blob, imageName.value);});
};const editAvatarConfirm = async () => {editAvatarDialog.value = false;const res = await uploadImage(previews.value.file);if (Array.isArray(res?.data) && res.data.length) {imageUrl.value = res.data[0].url;imageName.value = res.data[0].filename;callbackMessage.value = {show: true,valid: true,content: `上传成功`,};} else {callbackMessage.value = {show: true,valid: false,content: `上传失败`,};}
};const downloadPreView = () => {let aLink = document.createElement("a");aLink.download = "头像裁剪后的效果图.png";cropper.value.getCropBlob((blob: Blob) => {aLink.href = window.URL.createObjectURL(blob);aLink.click();});
};const rotateLeft = () => {cropper.value.rotateLeft();
};const rotateRight = () => {cropper.value.rotateRight();
};const changeScale = (scaleSize: number) => {cropper.value.changeScale(scaleSize);
};const handleDeleteAvatar = () => {if (!imageName.value) {imageName.value = imageUrl.value?.split("/").pop() || "";}$fetch(`/api/upload/delete`, {body: { filename: imageName.value },method: "POST",}).then((res) => {callbackMessage.value = {show: true,valid: true,content: `删除成功`,};imageUrl.value = "";});
};const handleEditAvatar = () => {if (imageUrl.value) {imageName.value = imageUrl.value.split("/").pop() || "";}option.value.img = imageUrl.value || "";editAvatarDialog.value = true;
};
</script><style scoped>
.previewBox {text-align: center;margin-left: 60px;
}.preview {width: 150px;height: 150px;margin: 0px auto 20px auto;border-radius: 50%;border: 1px solid #ccc;background-color: #ccc;overflow: hidden;
}.cropper {width: 260px;height: 260px;
}.avatar-uploader .avatar {width: 178px;height: 178px;display: block;
}.avatar-container {position: relative;
}.avatar:hover + .avatar-actions,
.avatar-actions:hover {display: flex;
}
</style><style>
.avatar-uploader .el-upload {border: 1px dashed var(--el-border-color);border-radius: 50% !important;cursor: pointer;position: relative;overflow: hidden;transition: var(--el-transition-duration-fast);
}.avatar-uploader .el-upload:hover {border-color: var(--el-color-primary);
}.el-icon.avatar-uploader-icon {font-size: 28px;color: #8c939d;width: 178px;height: 178px;text-align: center;
}
</style>
相关组件
components/SUI/S-msgWin.vue
<script lang="ts" setup>
const props = defineProps({msg: {type: Object,required: true,},top: {type: String,default: "50%",},duration: {type: Number,default: 3000,},
});
watch(() => props.msg,(newVal, oldVal) => {if (newVal.show) {setTimeout(() => {props.msg.show = false;}, props.duration);}}
);
</script>
<template><transition name="fade"><divv-show="msg.show"class="msgBox":class="{'border-#fde2e2 bg-red-50 text-#f56c6c': !msg.valid,'border-green-800 bg-green-50 text-green-500': msg.valid,}"><S-icon :icon="msg.valid ? 'ep:success-filled' : 'ix:error-filled'" /><div class="whitespace-nowrap">{{ msg.content }}</div><S-iconv-if="msg.closeable"class="c-#a8abb2 cursor-pointer"icon="material-symbols:close-rounded"@click="msg.show = false"/></div></transition>
</template>
<style scoped>
.msgBox {font-size: 14px;position: absolute;display: flex;flex-wrap: nowrap;align-items: center;gap: 6px;padding: 8px 10px;top: v-bind(props.top);left: 50%;transform: translate(-50%, -50%);z-index: 9999;border-radius: 4px;
}.fade-leave-from,
.fade-enter-to {opacity: 1;
}
.fade-leave-to,
.fade-enter-from {opacity: 0;
}
/* 定义过渡的持续时间和动画效果 */
.fade-enter-active,
.fade-leave-active {transition: opacity 0.3s ease;
}
</style>
相关组合式函数
composables/useCallbackMessage.ts
export const useCallbackMessage = () => {const callbackMessage = ref({show: false,valid: true,content: "",});return callbackMessage;
};
composables/useImageUpload.ts
export const useImageUpload = () => {const isLoading = ref(false);const error = ref<string | null>(null);const uploadImage = async (file: File) => {isLoading.value = true;error.value = null;try {const formData = new FormData();formData.append("image", file);const response = await $fetch("/api/upload/image", {method: "POST",body: formData,headers: {Accept: "application/json",},});return response;} catch (err: any) {error.value = err.message || "上传失败,请重试";throw err;} finally {isLoading.value = false;}};return {uploadImage,isLoading,error,};
};
页面使用
<S-avatar:disabled="action === 'detail'"v-model="formData.avatar"/>
Nuxt 中使用
因 vue-cropper 不支持服务端渲染,所以必须限定其仅在客户端渲染
import { ref, onMounted } from "vue";
import { defineAsyncComponent } from "vue";// 标记客户端环境
const isClient = ref(false);// 动态导入组件,禁用SSR
const AvatarCropper = defineAsyncComponent({loader: () => import("~/components/SUI/S-avatar.vue"),suspensible: false, // 关键:禁止在服务端渲染该组件,使用 suspensible 替代 ssr
});onMounted(() => {isClient.value = true; // 确保在客户端挂载后才显示组件
});
<AvatarCropper:disabled="action === 'detail'"v-if="isClient"v-model="formData.avatar"/>