基于 Vue 实现上传图片裁剪功能

3,095 阅读2分钟

效果如下

operate.gif

实现

需求分析

  1. 上传图片
  2. 可对图片进行裁剪且可预览
  3. 图片可放大、缩小、旋转
  4. 可重新上传图片

设计知识点

vue-cropper插件

需求实现

引入相关 Upload 组件

一开始样式相关的内容就直接 pass 了,最后会把所有代码贴上来,这里只放关键流程。本项目使用的是antdvElementUI 流程一样。

<a-upload
  v-model:file-list="fileList"
  list-type="picture-card"
  class="avatar-uploader"
  :show-upload-list="false"
  action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
  :before-upload="beforeUpload"
  :custom-request="handleUpload"
>
  ...code
</a-upload>

这里有两个需要注意的点:

  1. 自定义Upload上传方法handleUpload,用URL.createObjectURL方法将上传的文件转成img标签可以使用的链接。
  2. 使用第三方插件vue-cropper执行图片裁剪的操作。

引入 vue-cropper 插件

安装

  • Vue2

    • npm install vue-cropper
      
  • Vue3

    • npm install vue-cropper@next
      

引入

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

配置


<script setup>
  const option = ref({
  size: 1,
  full: false,
  outputType: 'png',
  canMove: true,
  fixedBox: true,  // fixedBox 效果是固定截图框大小。若需要更改截图框大小将属性改为 false 即可
  fixed: true,
  original: false,
  canMoveBox: true,
  autoCrop: true,
  autoCropWidth: 200,
  autoCropHeight: 200,
  centerBox: false,
  high: true,
  fixedNumber: [1, 1],
  max: 2000,
})
</script>
​
<template>
<div class="cut">
  <VueCropper
    @real-time="realTime"
    ref="cropper"
    :img="imageUrl"
    :output-size="option.size"
    :output-type="option.outputType"
    :info="true"
    :full="option.full"
    :fixed="option.fixed"
    :fixed-number="option.fixedNumber"
    :can-move="option.canMove"
    :can-move-box="option.canMoveBox"
    :fixed-box="option.fixedBox"
    :original="option.original"
    :auto-crop="option.autoCrop"
    :auto-crop-width="option.autoCropWidth"
    :auto-crop-height="option.autoCropHeight"
    :center-box="option.centerBox"
    :high="option.high"
    mode="cover"
    :max-img-size="option.max"
  />
</div>
</template>

这里一定要给VueCropper外面包一层,外面容器的宽高就是它的宽高。

预览

<script>
  const realTime = data => {
    previews.value = data
  }
</script>
​
<template>
  <div
    class="show-preview"
    :style="{
      'width': previews?.w + 'px',
      'height': previews?.h + 'px',
      'overflow': 'hidden',
    }"
  >
    <div :style="previews?.div">
      <img :src="previews?.url" :style="previews?.img">
    </div>
  </div>
</template>

要注意的是这里previews的宽高都是在配置中设置的autoCropWidthautoCropHeight

到这里基本关键部分就差不多了,放大缩小旋转都可以在相关文档上查询。

还有一点最关键的,这个插件只能把图片转成Base64Blob文件。所以如果要完成最后一步上传图片的话可能需要费点事,这里提供几个方向供大家参考:

  1. Base64传给后端,让后端生产一个文件地址,我们保存后端返给我们的这个文件地址。

  2. Base64再转回File文件,然后再调用上传接口,保存上传接口返回的文件地址。方法如下,需要注意该方法目前仅支持谷歌和火狐

  function dataURLtoFile(dataurl, filename) {
    var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
        bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
    while(n--){
        u8arr[n] = bstr.charCodeAt(n);
    }
    return new File([u8arr], filename, {type:mime});
  }

  const Base64Path = '...'
  const file = dataURLtoFile(Base64Path, 'fileName')

完整代码

<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue';
import { PlusOutlined, PlusCircleOutlined, MinusOutlined, UndoOutlined, RedoOutlined } from '@ant-design/icons-vue';
import { VueCropper } from 'vue-cropper'
import 'vue-cropper/dist/index.css'

const outerImage = ref(undefined)

const visible = ref(false)
const fileList = ref([]);
const imageUrl = ref('');
const previews = ref(null)
const cropper = ref()
const option = ref({
  size: 1,
  full: false,
  outputType: 'png',
  canMove: true,
  fixedBox: true,
  fixed: true,
  original: false,
  canMoveBox: true,
  autoCrop: true,
  // 只有自动截图开启 宽度高度才生效
  autoCropWidth: 200,
  autoCropHeight: 200,
  centerBox: false,
  high: true,
  fixedNumber: [1, 1],
  max: 2000,
})

const beforeUpload = file => {
  const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
  if (!isJpgOrPng) {
    message.error('You can only upload JPG file!');
  }
  const isLt2M = file.size / 1024 / 1024 < 6;
  if (!isLt2M) {
    message.error('Image must smaller than 6MB!');
  }
  return isJpgOrPng && isLt2M;
};
const handleUpload = file => {
  console.log('[ file ] >', file)
  imageUrl.value = URL.createObjectURL(file.file);
}
const realTime = (data) => {
  previews.value = data
}
const handlePlus = () => {
  cropper.value.changeScale(1)
}
const handleMinus = () => {
  cropper.value.changeScale(-1)
}
const handleTurnLeft = () => {
  cropper.value.rotateLeft()
}
const handleTurnRight = () => {
  cropper.value.rotateRight()
}
const handleSubmit = () => {

  cropper.value.getCropData((data) => {
    outerImage.value = data
    console.log('[ data ] >', data)
  });

  visible.value = false
}
</script>

<template>
  <div class="avatar-wrapper" v-if="!outerImage" @click="visible = true">
    <PlusOutlined />
  </div>
  <img class="avatar-wrapper" v-else :src="outerImage" alt="outerImage">
  <a-modal
    v-model:open="visible"
    title="选择图片"
    @ok="handleSubmit"
    width="720px"
    cancelText="取消"
    okText="确定"
    :maskClosable="false"
  >
    <div class="modal-content">
      <a-upload
        v-if="!imageUrl"
        ref="upload"
        v-model:file-list="fileList"
        list-type="picture-card"
        class="avatar-uploader"
        :show-upload-list="false"
        action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
        :before-upload="beforeUpload"
        :custom-request="handleUpload"
      >
        <div class="img-wrapper">
          <PlusCircleOutlined
            :style="{
              fontSize: '20px',
              marginBottom: '5px',
            }"
          />
          <span class="img-tips">选择图片</span>
        </div>
        
      </a-upload>
      <div v-else class="cut">
        <VueCropper
          @real-time="realTime"
          ref="cropper"
          :img="imageUrl"
          :output-size="option.size"
          :output-type="option.outputType"
          :info="true"
          :full="option.full"
          :fixed="option.fixed"
          :fixed-number="option.fixedNumber"
          :can-move="option.canMove"
          :can-move-box="option.canMoveBox"
          :fixed-box="option.fixedBox"
          :original="option.original"
          :auto-crop="option.autoCrop"
          :auto-crop-width="option.autoCropWidth"
          :auto-crop-height="option.autoCropHeight"
          :center-box="option.centerBox"
          :high="option.high"
          mode="cover"
          :max-img-size="option.max"
        />
      </div>
      <div class="content-right">
        <div class="avatar default" v-if="!imageUrl">图片</div>
        <div v-else>
          <div
            class="show-preview"
            :style="{
              'width': previews?.w + 'px',
              'height': previews?.h + 'px',
              'overflow': 'hidden',
            }"
          >
          <div :style="previews?.div">
            <img :src="previews?.url" :style="previews?.img">
          </div>
        </div>
        </div>

        <div class="upload-tips">
          <div class="upload-tip">请按照以下建议上传图片以达到最佳展示效果:</div>
          <div class="upload-tip">1、图片大小不超过6MB</div>
          <div class="upload-tip">2、支持jpg、png、jpeg</div>
          <div class="upload-tip">3、图片长宽比为1:1</div>
        </div>
      </div>
    </div>
    <div class="operate-wrapper" v-if="imageUrl">
      <a-upload
        ref="upload"
        v-model:file-list="fileList"
        class="avatar-uploader"
        :show-upload-list="false"
        action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
        :before-upload="beforeUpload"
        :custom-request="handleUpload"
      >
        <a-button>重新上传</a-button>
      </a-upload>
        <div class="cropper-btns">
        <PlusOutlined
          @click="handlePlus"
          :style="{
            cursor: 'pointer',
            fontSize: '16px',
            marginRight: '15px'
          }"
        />
        <MinusOutlined
          @click="handleMinus"
          :style="{
            cursor: 'pointer',
            fontSize: '16px',
            marginRight: '15px'
          }"
        />
        <UndoOutlined
          @click="handleTurnLeft"
          :style="{
            cursor: 'pointer',
            fontSize: '16px',
            marginRight: '15px'
          }"
        />
        <RedoOutlined
          @click="handleTurnRight"
          :style="{
            cursor: 'pointer',
            fontSize: '16px',
            marginRight: '10px'
          }"
        />
      </div>
    </div>
  </a-modal>
</template>

<style scoped lang="less">
  .avatar-wrapper {
    cursor: pointer;
    width: 200px;
    height: 200px;
    line-height: 200px;
    text-align: center;
    border: 1px dashed #d9d9d9;
    border-radius: 5px;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
  .modal-content {
    display: flex;
    justify-content: space-between;
    .img-wrapper {
      cursor: pointer;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;
      border-radius: 10px;
      background-color: #f4f4fb;
    }
    .content-right {
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      .avatar {
        border-radius: 50%;
        display: inline-block;
        width: 200px;
        height: 200px;
        font-size: 20px;
        text-align: center;
        line-height: 200px;
        font-weight: 400;
        background-color: #f4f4fb;
      }
      .upload-tips {
        font-size: 12px;
        width: 200px;
        line-height: 18px;
      }
    }
    :deep(.ant-upload.ant-upload-select.ant-upload-select-picture-card) {
      width: 450px !important;
      height: 400px !important;
    }
  }
  .cut {
    height: 400px;
    width: 450px;
    margin-bottom: 8px;
  }
  .show-preview {
    border-radius: 50%;
    display: inline-block;
    font-weight: 700;
    background-color: #f4f4fb;
  }
  .operate-wrapper {
    width: 450px;
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

</style>