最终效果
技术要点
图片裁剪
安装依赖 vue-cropper
npm install vue-cropper@next
专用于vue3 项目的图片裁剪,详细使用参考官方文档
页面使用
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
<vue-cropper
ref="cropper"
v-bind="option"
@realTime="realTime"
></vue-cropper>
const cropper = ref();
const option = ref({
autoCrop: true, // 是否默认生成截图框
autoCropHeight: "240px", // 默认生成截图框宽度(默认值:容器的 80%, 可选值:0 ~ max), 真正裁剪出来的图片的宽度为 autoCropHeight * 1.25
autoCropWidth: "240px", // 默认生成截图框宽度(默认值:容器的 80%, 可选值:0 ~ max), 真正裁剪出来的图片的宽度为 autoWidth * 1.25
canMove: 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-upload
class="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-image
class="avatar"
:src="imageUrl"
fit="cover"
:preview-src-list="[imageUrl]"
/>
<div
v-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-cropper
ref="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-upload
action="#"
: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"
circle
size="small"
@click="changeScale(1)"
></el-button>
<el-button
:icon="ZoomOut"
circle
size="small"
@click="changeScale(-1)"
></el-button>
<el-button
:icon="Download"
circle
size="small"
@click="downloadPreView"
></el-button>
<el-button
:icon="RefreshLeft"
circle
size="small"
@click="rotateLeft"
></el-button>
<el-button
:icon="RefreshRight"
circle
size="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.25
autoCropWidth: "240px", // 默认生成截图框宽度(默认值:容器的 80%, 可选值:0 ~ max), 真正裁剪出来的图片的宽度为 autoWidth * 1.25
canMove: 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">
<div
v-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-icon
v-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"
/>