一、效果展示
首页效果
点击更换头像进入裁剪页面
点击保存上传图片
上传完成后的效果
二、代码实现
本项目用到了vant组件库,less预处理器,请自行下载配置。
安装cropperjs库
npm i cropperjs
点击头像跳转到头像编辑页面
<img class="image" @click="toEditAva" :src="avatar" alt="" />
// 跳转修改头像
const toEditAva = () => {
router.push("/editAva");
};
头像编辑页面代码
<template>
<div class="edit-ava">
<div v-if="isShowImg">
<div class="content">
<div class="image">
<img :src="avatar" alt="" />
</div>
</div>
<div class="edit-btn" @click="showCropper">
<span>更换头像</span>
</div>
</div>
<div class="loading">
<van-loading color="#ff6633" type="spinner" size="24px">上传中...</van-loading>
</div>
<!-- 头像裁剪组件 -->
<AvatarCropper
v-model="isShowCropper"
upload-url="/api/upload/blog"
@uploaded="handleUploaded"
@showImg="showImg"
/>
</div>
</template>
<script setup>
import useUserStore from "@/stores/modules/user.js";
import { showNotify } from "vant";
import { ref, reactive } from "vue";
import { storeToRefs } from "pinia";
// 引入图片裁剪组件
import AvatarCropper from "../../components/avatar-corpper/AvatarCropper.vue";
const userStore = useUserStore();
const { avatar } = storeToRefs(userStore);
const isShowCropper = ref(false);
const isShowImg = ref(true);
const showCropper = () => {
isShowCropper.value = true;
};
const showImg = () => {
isShowImg.value = false;
};
// 裁剪并上传完成后,将图片更换为上传后的图片
const handleUploaded = (blob) => {
isShowImg.value = true;
console.log(blob);
if (blob.res?.success) {
userStore.avatar = blob.res.data;
} else {
showNotify({
type: "warning",
message: "上传失败,请检查网络连接",
onClose: () => {
isShowCropper.value = false;
isShowImg.value = true;
},
});
}
};
</script>
<style lang="less" scoped>
.edit-ava {
height: 100vh;
background-color: #333;
overflow: hidden;
.content {
padding-top: 120px;
display: flex;
justify-content: center;
.image {
width: 280px;
height: 280px;
img {
width: 100%;
height: 100%;
}
}
}
.loading {
position: fixed;
left: 40%;
z-index: 0;
padding-top: 60%;
}
.edit-btn {
width: 100vw;
margin-top: 100px;
display: flex;
justify-content: center;
span {
width: 120px;
height: 34px;
color: #ccc;
text-align: center;
line-height: 34px;
font-size: 14px;
font-weight: 600;
border: #ccc solid 2px;
border-radius: 20px;
}
}
}
</style>
图片裁剪组件代码
裁剪后的图片上传到后端在uploadImage方法中,这里没有写具体发送请求的代码
<template>
<div class="avatar-cropper">
<div
class="avatar-cropper-overlay"
:class="{ 'avatar-cropper-overlay-inline': inline }"
v-if="dataUrl"
>
<div class="avatar-cropper-container">
<div class="avatar-cropper-image-container">
<img
ref="img"
:src="dataUrl"
alt
@load.stop="createCropper"
@error="onImgElementError"
/>
</div>
<div class="avatar-cropper-footer">
<div class="edit-btn">
<span @click.stop.prevent="submit">保存</span>
</div>
</div>
</div>
</div>
<input
v-if="!file"
:accept="cleanedMimes"
:capture="capture"
class="avatar-cropper-img-input"
ref="input"
type="file"
@change="onFileInputChange"
/>
</div>
</template>
<script>
import { fileUpload } from "@/services";
import "cropperjs/dist/cropper.css";
import Cropper from "cropperjs";
import mime from "mime/lite";
import { defineComponent } from "vue";
export default defineComponent({
name: "AvatarCropper",
emits: [
"update:modelValue",
"submit",
"error",
"cancel",
"changed",
"uploading",
"completed",
"uploaded",
"showImg",
],
props: {
modelValue: {
type: Boolean,
default: false,
},
file: {
type: File,
},
uploadHandler: {
type: Function,
},
uploadUrl: {
type: String,
},
requestOptions: {
type: Object,
default() {
return {
method: "POST",
};
},
},
uploadFileField: {
type: String,
default: "file",
},
uploadFileName: {
type: [String, Function],
},
uploadFormData: {
type: FormData,
default() {
return new FormData();
},
},
cropperOptions: {
type: Object,
default() {
return {
aspectRatio: 1,
autoCropArea: 1,
viewMode: 1,
movable: false,
zoomable: false,
};
},
},
outputOptions: {
type: Object,
},
outputMime: {
type: String,
default: null,
},
outputQuality: {
type: Number,
default: 0.9,
},
mimes: {
type: String,
default: "image/png, image/gif, image/jpeg, image/bmp, image/x-icon",
},
capture: {
type: String,
},
labels: {
type: Object,
default() {
return {
submit: "确定",
cancel: "取消",
};
},
},
inline: {
type: Boolean,
default: false,
},
},
data() {
return {
cropper: undefined,
dataUrl: undefined,
fileName: undefined,
mimeType: undefined,
};
},
computed: {
cleanedMimes() {
if (!this.mimes)
throw new Error("vue-avatar-cropper: mimes prop cannot be empty");
return this.mimes.trim().toLowerCase();
},
},
watch: {
modelValue(value) {
if (!value) return;
if (this.file) {
this.onFileChange(this.file);
} else {
this.pickImage();
}
this.$emit("update:modelValue", false);
},
},
mounted() {
this.$emit("update:modelValue", false);
},
methods: {
destroy() {
if (this.cropper) this.cropper.destroy();
if (this.$refs.input) this.$refs.input.value = "";
this.dataUrl = undefined;
},
submit() {
this.$emit("submit");
if (this.uploadUrl) {
this.uploadImage();
} else if (this.uploadHandler) {
// } else if (true) {
// console.log(this.cropper);
this.uploadHandler(this.cropper);
} else {
this.$emit("error", {
type: "user",
message: "No upload handler found",
});
}
this.destroy();
},
cancel() {
this.$emit("cancel");
this.destroy();
},
onImgElementError() {
this.$emit("error", {
type: "load",
message: "File loading failed",
});
this.destroy();
},
pickImage() {
if (this.$refs.input) this.$refs.input.click();
},
onFileChange(file) {
if (this.cleanedMimes === "image/*") {
if (file.type.split("/")[0] !== "image") {
this.$emit("error", {
type: "user",
message: "File type not correct",
});
return;
}
} else if (this.cleanedMimes) {
const correctType = this.cleanedMimes
.split(", ")
.find((mime) => mime === file.type);
if (!correctType) {
this.$emit("error", {
type: "user",
message: "File type not correct",
});
return;
}
}
const reader = new FileReader();
reader.onload = (e) => {
this.dataUrl = e.target.result;
};
reader.readAsDataURL(file);
this.fileName = file.name || "unknown";
this.mimeType = file.type;
this.$emit("changed", {
file,
reader,
});
},
onFileInputChange(e) {
this.$emit("showImg");
if (!e.target.files || !e.target.files[0]) return;
this.onFileChange(e.target.files[0]);
},
createCropper() {
this.cropper = new Cropper(this.$refs.img, this.cropperOptions);
},
getFilename(blob) {
const extension = mime.getExtension(blob.type);
// Default logic
if (!this.uploadFileName) {
let actualFilename = this.fileName;
const filenameParts = this.fileName.split(".");
if (filenameParts.length > 1)
actualFilename = filenameParts.slice(0, -1).join(".");
return `${actualFilename}.${extension}`;
}
// User provided filename
if (typeof this.uploadFileName === "string") return this.uploadFileName;
if (typeof this.uploadFileName === "function")
return this.uploadFileName({
filename: this.fileName,
mime: blob.type,
extension,
});
return `unknown.${extension}`;
},
// 上传裁剪后的图片
uploadImage() {
this.cropper.getCroppedCanvas(this.outputOptions).toBlob(
// (blob) => {
async (blob) => {
const form = new FormData();
for (const [key, value] in this.uploadFormData.entries()) {
form.append(key, value);
}
form.append(this.uploadFileField, blob, this.getFilename(blob));
// 将裁剪后得到blob文件上传
const res = await fileUpload(blob);
this.$emit("uploaded", {
res: res,
});
}
);
},
},
});
</script>
<style lang="less">
.avatar-cropper {
height: calc(100vh - 46px);
background-color: #333;
.avatar-cropper-overlay {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
padding-top: 100px;
z-index: 99999;
}
.avatar-cropper-overlay-inline {
position: initial;
}
.avatar-cropper-img-input {
display: none;
}
.avatar-cropper-close {
float: right;
padding: 20px;
font-size: 3rem;
color: #fff;
font-weight: 100;
text-shadow: 0px 1px rgba(40, 40, 40, 0.3);
}
.avatar-cropper-mark {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
}
.avatar-cropper-container {
// background: #fff;
z-index: 999;
box-shadow: 1px 1px 5px rgba(100, 100, 100, 0.14);
.avatar-cropper-image-container {
display: flex;
justify-content: center;
position: relative;
max-width: 400px;
height: 300px;
}
img {
max-width: 100%;
height: 100%;
}
.avatar-cropper-footer {
.edit-btn {
width: 100vw;
margin-top: 100px;
display: flex;
justify-content: center;
span {
width: 120px;
height: 34px;
color: #ccc;
text-align: center;
line-height: 34px;
font-size: 14px;
font-weight: 600;
border: #ccc solid 2px;
border-radius: 20px;
}
}
}
}
}
</style>