Vue3 + cropper 实现裁剪头像的功能(裁剪效果可实时预览、预览图可下载、预览图可上传到SpringBoot后端、附完整的示例代码和源代码)

avatar
作者
猴君
阅读量:0

文章目录

0. 前言

裁剪头像的需求十分常见,主要目的是为了统一用户头像的尺寸,避免因为用户上传的图片尺寸大小不一致导致页面布局出现问题

高效实现需求的方法,就是避免重复造轮子,在这里推荐使用 cropper 实现头像裁剪功能 (原因是 cropper 功能强大、上手简单、文档详细)


cropper 的Gitee地址:vue-cropper

cropper Vue3在线示例:cropper Vue3在线示例

1. 裁剪效果(可实时预览)

在这里插入图片描述

2. 安装 cropper

# npm 安装 npm install vue-cropper@next 
# yarn 安装 yarn add vue-cropper@next 

3. 引入 Vue Cropper

3.1 局部引入(推荐使用)

哪个组件需要使用 Vue Cropper,就在哪个组件导入

import 'vue-cropper/dist/index.css' import { VueCropper }  from 'vue-cropper' 

3.2 全局引入

main.js 文件

import VueCropper from 'vue-cropper' import 'vue-cropper/dist/index.css'  const app = createApp(App) app.use(VueCropper) app.mount('#app') 

4. 在代码中使用

注意事项:

要为 <vue-cropper></vue-cropper> 组件设置宽和高,并用一个外层容器包裹 <vue-cropper></vue-cropper> 组件

4.1 template部分

<vue-cropper     class="crop"     ref="cropper"     :autoCrop="option.autoCrop"     :autoCropHeight="option.autoCropHeight"     :autoCropWidth="option.autoCropWidth"     :canMove="option.canMove"     :canScale="option.canScale"     :centerBox="option.centerBox"     :fixed="option.fixed"     :fixedBox="option.fixedBox"     :fixedNumber="option.fixedNumber"     :img="option.img"     :info-true="option.infoTrue"     :mode="option.mode"     :origin="option.origin"     :outputSize="option.outputSize"     :outputType="option.outputType"     @realTime="realTime" ></vue-cropper> 

4.2 script部分

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: 'https://img2.baidu.com/it/u=2339635883,2403687892&fm=253&fmt=auto&app=138&f=JPEG', // 裁剪图片的地址(可选值:url 地址, base64, blob)   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) })  // 实时预览 const realTime = (data) => {   // console.log('realTime data =', data)   previews.value = data } 

5. 注意事项

  1. cropper 对象的 getCropBlob 方法和 getCropData 方法都是异步方法
  2. 虽然 getCropBlob 获取的的 Blob 对象在控制台打印时只有 size 和 type 属性,但是仍然可以使用window.URL.createObjectURL(blob)来生成 url ,从 Java 的角度来说,相当于重写了 Blob 类的 toString 方法
  3. 前端用 formData 上传文件时, key 要与后端接口中 @RequestParam(“avatar”) 指定的参数名一致

在这里插入图片描述

6. SpringBoot 后端接收图片

后端环境:

  • JDK:17.0.7
  • SpringBoot:3.0.2

6.1 UserController.java

import cn.edu.scau.controller.vo.Result; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile;  import java.io.File; import java.io.IOException; import java.util.Objects; import java.util.UUID;  @RestController @RequestMapping("/user") public class UserController {      @PostMapping("/updateAvatar")     public Result<Object> updateAvatar(@RequestParam("avatar") MultipartFile avatar) {         System.err.println("文件名:" + avatar.getOriginalFilename());         System.err.println("文件大小(KB):" + avatar.getSize() / 1024);          try {             // 拿到图片文件后,可以将图片上传到阿里云、腾讯云、minio等第三方存储服务,然后返回图片的访问地址             // 这里直接保存到本地              String fileName = UUID.randomUUID().toString();             String suffix = Objects.requireNonNull(avatar.getOriginalFilename()).substring(avatar.getOriginalFilename().lastIndexOf("."));             avatar.transferTo(new File("F:\\Blog\\crop-avatar\\" + fileName + suffix));         } catch (IOException ioException) {             throw new RuntimeException(ioException);         }          return Result.success();     }  } 

6.2 Result.java

import java.io.Serializable;  /**  * 后端统一返回结果  *  * @param <T>  */ public class Result<T> implements Serializable {      private Integer code;     private String message;     private T data;      public static <T> Result<T> success() {         Result<T> result = new Result<>();         result.code = 200;         result.message = "success";         return result;     }      public static <T> Result<T> success(T object) {         Result<T> result = new Result<>();         result.data = object;         result.code = 200;         result.message = "success";         return result;     }      public static <T> Result<T> fail(String message) {         Result<T> result = new Result<>();         result.message = message;         result.code = 500;         return result;     }      public Integer getCode() {         return code;     }      public void setCode(Integer code) {         this.code = code;     }      public String getMessage() {         return message;     }      public void setMessage(String message) {         this.message = message;     }      public T getData() {         return data;     }      public void setData(T data) {         this.data = data;     }      @Override     public String toString() {         return "Result{" +                 "code=" + code +                 ", message='" + message + '\'' +                 ", data=" + data +                 '}';     }  } 

7. 完整的示例代码

7.1 Homeview.vue

<template>   <div class="wrapper">     <div class="blank-line"></div>     <div class="top">       <p class="title">裁剪头像</p>     </div>     <div class="blank-line"></div>     <div class="main">       <div class="crop-container">         <vue-cropper             class="crop"             ref="cropper"             :autoCrop="option.autoCrop"             :autoCropHeight="option.autoCropHeight"             :autoCropWidth="option.autoCropWidth"             :canMove="option.canMove"             :canScale="option.canScale"             :centerBox="option.centerBox"             :fixed="option.fixed"             :fixedBox="option.fixedBox"             :fixedNumber="option.fixedNumber"             :img="option.img"             :info="option.info"             :info-true="option.infoTrue"             :mode="option.mode"             :origin="option.origin"             :outputSize="option.outputSize"             :outputType="option.outputType"             :rounded="true"             @realTime="realTime"         ></vue-cropper>          <input             id="input"             ref="input"             type="file"             accept="image/png, image/jpeg, image/gif, image/jpg"             @change="uploadAvatar($event)"             v-show="false">          <div class="action-buttons">           <el-button :size="'default'" type="primary" @click="handleUploadAvatar">上传图片</el-button>           <el-button :size="'default'" type="danger" plain :icon="ZoomIn" @click="changeScale(1)">             放大(向上滚动鼠标滑轮)           </el-button>           <el-button :size="'default'" type="danger" plain :icon="ZoomOut" @click="changeScale(-1)">             缩小(向下滚动鼠标滑轮)           </el-button>           <el-button :size="'default'" type="primary" @click="rotateLeft">向左旋转</el-button>           <el-button :size="'default'" type="primary" @click="rotateRight">向右旋转</el-button>           <el-button :size="'default'" type="primary" @click="downloadPreView">下载预览图</el-button>           <el-button :size="'default'" type="primary" @click="updateAvatar">确定修改</el-button>         </div>       </div>        <div class="preview-container">         <div>           <p class="preview-title">实时预览</p>         </div>         <div :style="getPreviewStyle">           <div :style="previews.div">             <img :src="previews.url" :style="previews.img" alt="" class="preview-img">           </div>         </div>       </div>     </div>   </div> </template>  <script setup> import 'vue-cropper/dist/index.css' import {VueCropper} from 'vue-cropper' import {computed, ref} from 'vue' import {ElMessage} from 'element-plus' import {ZoomIn, ZoomOut} from '@element-plus/icons-vue' import request from '@/util/request.js'  const previews = ref({}) const previewBlob = ref() const previewBase64 = ref()  const cropper = ref() const input = 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: 'https://img1.baidu.com/it/u=3450282427,2041051230&fm=253', // 裁剪图片的地址(可选值:url 地址, base64, blob)   info: false, // 是否显示裁剪框的宽高信息   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) })  // 实时预览 const realTime = (data) => {   // console.log('realTime data =', data)   previews.value = data }  const downloadPreView = () => {   let aLink = document.createElement('a')   aLink.download = '预览图.png'    cropper.value.getCropBlob((blob) => {     aLink.href = window.URL.createObjectURL(blob)     aLink.click()   }) }  const uploadAvatar = (event) => {   let file = event.target.files[0]   // console.log('uploadAvatar file=', file)    if (!/\.(gif|jpg|jpeg|png|bmp)$/i.test(event.target.value)) {     ElMessage.error('图片类型必须是.gif、jpeg、jpg、png、bmp中的一种')     return false   }    let fileReader = new FileReader()   fileReader.onload = (event) => {     let data     if (typeof event.target.result === 'object') {       // 把 Array Buffer 转化为 blob       data = window.URL.createObjectURL(new Blob([event.target.result]))     } else {       // 如果是 base64 ,不需要转换       data = event.target.result     }     option.value.img = data   }    // 转化为base64   // fileReader.readAsDataURL(file)    // 转化为blob   fileReader.readAsArrayBuffer(file) }  const handleUploadAvatar = () => {   input.value.click() }  const getPreviewStyle = computed(() => {   return {     'width': previews.value.w + 'px',     'height': previews.value.h + 'px',     'overflow': 'hidden',     // 'border-radius': '50%'   } })  const rotateLeft = () => {   cropper.value.rotateLeft() }  const rotateRight = () => {   cropper.value.rotateRight() }  const changeScale = (scaleSize) => {   cropper.value.changeScale(scaleSize) }  // 注意:getCropData是一个异步方法 const getBase64 = () => {   cropper.value.getCropData((base64) => {     previewBase64.value = base64     console.log('previewBase64 =', previewBase64.value)   }) }  // 注意:getCropBlob是一个异步方法 const getBlob = () => {   cropper.value.getCropBlob((blob) => {     previewBlob.value = blob     // 虽然 getCropBlob 方法获取的的 Blob 对象在控制台打印时只有 size 和 type 属性,但是仍然可以使用 window.URL.createObjectURL(blob) 生成 url     // 从 Java 的角度来说,相当于重写了 Blob 类的 toString 方法     console.log('previewBlob =', previewBlob.value)   }) }  const updateAvatar = async () => {   cropper.value.getCropBlob((blob) => {     let avatar = new File([blob], 'avatar.png')     let formData = new FormData()     formData.append('avatar', avatar)      request         .post('/user/updateAvatar', formData, {           headers: {             'Content-Type': 'multipart/form-data'           }         })         .then((response) => {           if (response.code === 200) {             ElMessage.success('修改头像成功')           } else {             ElMessage.error('修改头像失败')           }         })         .catch((error) => {           console.log('error =', error)           ElMessage.error('修改头像失败')         })   }) } </script>  <style scoped> .title {   font-size: 40px;   text-align: center; }  .main {   display: flex;   justify-content: space-around; }  .crop {   width: 925px;   height: 500px; }  .action-buttons {   display: flex;   justify-content: space-between;   margin-top: 10px; }  .blank-line {   height: 20px;   width: 100%; }  .preview-img {   border: 5px solid black; }  .preview-title {   font-size: 20px;   margin-bottom: 10px;   text-align: center; } </style> 

7.2 request.js

import axios from 'axios'  const request = axios.create({   baseURL: '/api',   timeout: 60000,   headers: {     'Content-Type': 'application/json;charset=UTF-8'   } })  request.interceptors.request.use(  )  request.interceptors.response.use(response => {   if (response.data) {     return response.data   }   return response }, (error) => {   return Promise.reject(error) })  export default request 

7.3 main.js

import '@/assets/main.css'  import {createApp} from 'vue' import {createPinia} from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import zhCn from 'element-plus/es/locale/lang/zh-cn' import * as ElementPlusIconsVue from '@element-plus/icons-vue'  import App from './App.vue' import router from './router' import 'default-passive-events'  const app = createApp(App)  app.use(createPinia()) app.use(ElementPlus, {locale: zhCn}) for (const [key, component] of Object.entries(ElementPlusIconsVue)) {   app.component(key, component) } app.use(router)  app.mount('#app') 

7.4 vite.config.js

import {fileURLToPath, URL} from 'node:url'  import {defineConfig} from 'vite' import vue from '@vitejs/plugin-vue'  // https://vitejs.dev/config/ export default defineConfig({   plugins: [     vue()   ],   resolve: {     alias: {       '@': fileURLToPath(new URL('./src', import.meta.url))     }   },   server: {     proxy: {       '/api': {         target: 'http://localhost:8001',         changeOrigin: true,         rewrite: (path) => {           return path.replace('/api', '')         }       }     }   } }) 

8. 完整的源代码

前端:cropper-avatar-frontend

后端:cropper-avatar-backend

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!