vue2 头像上传+裁剪组件封装

发布于:2025-05-15 ⋅ 阅读:(13) ⋅ 点赞:(0)

背景:最近在进行公司业务开发时,遇到了头像上传限制尺寸的需求,即限制为一寸证件照(宽295像素,高413像素)。

用到的第三方库: "vue-cropper": "^0.5.5"

完整组件代码:

avatarUpload.vue

<!-- 上传图片并裁剪 -->
<template>
  <div :style="{ marginTop: marginTop + 'px' }">
    <!-- 上传 -->
    <template>
      <div class="preview-img" v-show="previewImg" :style="{ width: uploadWidth + 'px', height: uploadHeight + 'px' }">
        <div class="preview-img-box" :style="{ lineHeight: uploadHeight + 'px' }">
          <i v-if="!disabled" class="el-icon-delete" @click="deleteFn"></i>
          <i class="el-icon-view" @click="viewFn"></i>
          <i v-if="!disabled" class="el-icon-refresh" @click="refreshFn"></i>
        </div>        
        <img :src="previewImg" :style="{ width: '100%', height: '100%', objectFit: objectFit }" />
      </div>

      <el-upload v-show="!previewImg" ref="upload" class="upload-demo"
        :style="{ width: uploadWidth + 'px', height: uploadHeight + 'px' }" :action="actionUrl"
        :on-change="handleChangeUpload" :auto-upload="false" :show-file-list="false" :disabled="disabled">
        <div class="upload-demo-icon" :style="{
          width: uploadWidth + 'px',
          height: uploadHeight + 'px',
          lineHeight: uploadHeight + 'px',
        }">
          +
        </div>
      </el-upload>
    </template>
    <!-- 裁剪 -->
    <el-dialog title="图片剪裁" :visible.sync="dialogVisible" class="crop-dialog" append-to-body>
      <div style="padding: 0 20px">
        <div :style="{
          textAlign: 'center',
          width: cropperWidth != 0 ? cropperWidth + 'px' : 'auto',
          height: cropperHeight + 'px',
        }">
          <VueCropper ref="cropper" :img="cropperImg" :output-size="outputSize" :output-type="outputType" :info="info"
            :full="full" :can-move="canMove" :can-move-box="canMoveBox" :original="original" :auto-crop="autoCrop"
            :can-scale="canScale" :fixed="fixed" :fixed-number="fixedNumber" :fixed-box="fixedBox"
            :center-box="centerBox" :info-true="infoTrue" :auto-crop-width="autoCropWidth"
            :auto-crop-height="autoCropHeight" />
        </div>
      </div>
      <!-- 这里的按钮可以根据自己的需求进行增删-->
      <div class="action-box" v-if="actionButtonFlag">
        <el-upload action="#" :auto-upload="false" :show-file-list="false" :on-change="handleChangeUpload"
          style="margin-right: 15px">
          <el-button title="更换图片" plain circle type="primary" icon="el-icon-refresh"></el-button>
        </el-upload>
        <el-button title="清除图片" plain circle type="primary" icon="el-icon-close" @click="clearImgHandle"></el-button>
        <el-button title="向左旋转" plain circle type="primary" icon="el-icon-refresh-left"
          @click="rotateLeftHandle"></el-button>
        <el-button title="向右旋转" plain circle type="primary" icon="el-icon-refresh-right" @click="rotateRightHandle">
        </el-button>
        <el-button title="放大" plain circle type="primary" @click="changeScaleHandle(1)" icon="el-icon-zoom-in">
        </el-button>
        <el-button title="缩小" plain circle type="primary" @click="changeScaleHandle(-1)" icon="el-icon-zoom-out">
        </el-button>
        <!-- <el-button type="primary" @click="fixed = !fixed">
            {{ fixed ? "固定比例" : "自由比例" }}
          </el-button> -->
        <el-button title="下载" plain circle type="primary" icon="el-icon-download"
          @click="downloadHandle('blob')"></el-button>
      </div>

      <div slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" :loading="loading" @click="finish">
          确认
        </el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script>
import { VueCropper } from "vue-cropper";
import { insertImage } from '@/api/file';
export default {
  name: "Cropper",
  components: {
    VueCropper,
  },
  props: {
    marginTop: {
      type: Number,
      default: 0,
    },
    // 上传属性

    // 图片路径
    imgSrc: {
      type: String,
      default: "",
    },
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false,
    },
    // 列表索引
    listIndex: {
      type: Number,
      default: null,
    },
    // 上传路径
    actionUrl: {
      type: String,
      default: "#",
    },
    // 上传宽度
    uploadWidth: {
      type: Number,
      default: 100,
    },
    // 上传高度
    uploadHeight: {
      type: Number,
      default: 100,
    },
    // 图片显示角度 传值详情参考mdn object-fit
    objectFit: {
      type: String,
      default: "fill",
    },

    // 裁剪属性

    // 裁剪弹出框的宽度
    cropperWidth: {
      type: Number,
      default: 0,
    },
    // 裁剪弹出框的高度
    cropperHeight: {
      type: Number,
      default: 600,
    },
    // 裁剪生成图片的质量 0.1-1
    outputSize: {
      type: Number,
      default: 1,
    },
    // 裁剪生成图片的格式
    outputType: {
      type: String,
      default: "png",
    },
    // 裁剪框的大小信息
    info: {
      type: Boolean,
      default: true,
    },
    // 是否输出原图比例的截图
    full: {
      type: Boolean,
      default: false,
    },
    // 截图框能否拖动
    canMove: {
      type: Boolean,
      default: true,
    },
    // 截图框能否拖动
    canMoveBox: {
      type: Boolean,
      default: true,
    },
    // 上传图片按照原始比例渲染
    original: {
      type: Boolean,
      default: true,
    },
    // 是否默认生成截图框
    autoCrop: {
      type: Boolean,
      default: true,
    },
    // 图片是否允许滚轮缩放
    canScale: {
      type: Boolean,
      default: true,
    },
    // 是否开启截图框宽高固定比例
    fixed: {
      type: Boolean,
      default: true,
    },
    // 截图框的宽高比例 开启fixed生效
    fixedNumber: {
      type: Array,
      default: () => [5, 7],
    },
    // 固定截图框大小 不允许改变
    fixedBox: {
      type: Boolean,
      default: true,
    },
    // 截图框是否被限制在图片里面
    centerBox: {
      type: Boolean,
      default: true,
    },
    // true 为展示真实输出图片宽高 false 展示看到的截图框宽高
    infoTrue: {
      type: Boolean,
      default: true,
    },
    // 默认生成截图框宽度
    autoCropWidth: {
      type: Number,
      default: 295,
    },
    // 默认生成截图框高度
    autoCropHeight: {
      type: Number,
      default: 413,
    },
    // 是否出现操作按钮
    actionButtonFlag: {
      type: Boolean,
      default: false,
    },
    // 裁剪路径输出格式 base64:base64; blob:blob;
    cropFormat: {
      type: String,
      default: "blob",
    },
    // 图片最大宽度
    maxImgWidth: {
      type: Number,
      default: 648,
    },
    // 图片最大高度
    maxImgHeight: {
      type: Number,
      default: 1152,
    },
    // 头像扫描件id
    txsmjid: {
      type: String,
      default: ""
    },
    // 是否禁用上传
    disabled:{
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      previewImg: "", // 预览图片地址
      dialogVisible: false, //图片裁剪弹框
      cropperImg: "", // 裁剪图片的地址
      loading: false, // 防止重复提交
      baseCsUrl: process.env.NODE_ENV === 'production' ? window.globalConfig.VUE_APP_BASE_API_CS : process.env.VUE_APP_BASE_API_CS, // 文件服务器地址
      fileName: "",
    };
  },
  watch: {
    imgSrc: {
      handler(newVal, oldVal) {
        this.previewImg = newVal;
      },
      deep: true, // 深度监听
      immediate: true, // 首次进入就监听
    },
  },
  methods: {
    // 上传按钮 限制图片大小和类型
    handleChangeUpload(file) {
      this.fileName = file.name;
      const isJPG =
        file.raw.type === "image/jpeg" || file.raw.type === "image/png";
      const isLt2M = file.size / 1024 / 1024 < 2;
      const min_isLt = file.size / 1024 > 20;
      if (!isJPG) {
        this.$message.error("上传头像图片只能是 JPG/PNG 格式!");
        return false;
      }
      if (!isLt2M) {
        this.$message.error("上传头像图片大小不能超过 2MB!");
        return false;
      }
      if (!min_isLt) {
        this.$modal.msgError("头像大小不能小于 20 K!");
        return false;
      }
      // 上传成功后将图片地址赋值给裁剪框显示图片
      this.$nextTick(async () => {
        // base64方式
        // this.option.img = await fileByBase64(file.raw)
        this.cropperImg = URL.createObjectURL(file.raw);
        this.loading = false;
        this.dialogVisible = true;
      });
    },
    // 放大/缩小
    changeScaleHandle(num) {
      num = num || 1;
      this.$refs.cropper.changeScale(num);
    },
    // 左旋转
    rotateLeftHandle() {
      this.$refs.cropper.rotateLeft();
    },
    // 右旋转
    rotateRightHandle() {
      this.$refs.cropper.rotateRight();
    },
    // 下载
    downloadHandle(type) {
      let aLink = document.createElement("a");
      aLink.download = "author-img";
      if (type === "blob") {
        this.$refs.cropper.getCropBlob((data) => {
          aLink.href = URL.createObjectURL(data);
          aLink.click();
        });
      } else {
        this.$refs.cropper.getCropData((data) => {
          aLink.href = data;
          aLink.click();
        });
      }
    },
    // 清理图片
    clearImgHandle() {
      this.cropperImg = "";
    },
    // 截图
    finish() {
      if (this.cropFormat == "base64") {
        // 获取截图的 base64 数据
        this.$refs.cropper.getCropData((data) => {
          this.loading = true;
          this.dialogVisible = false;

          this.getImgHeight(data, this.maxImgWidth, this.maxImgHeight).then(
            (imgUrl) => {
              // console.log(imgUrl, "base64");
              this.previewImg = imgUrl;
              if (this.listIndex !== null) {
                this.$emit("successCheng", this.previewImg, this.listIndex);
              } else {
                this.$emit("successCheng", this.previewImg);
              }
            }
          );
        });
      } else if (this.cropFormat == "blob") {
        // 获取截图的 blob 数据
        this.$refs.cropper.getCropBlob((blob) => {
          this.loading = true;
          // this.dialogVisible = false;

          this.getImgHeight(
            URL.createObjectURL(blob),
            this.maxImgWidth,
            this.maxImgHeight
          ).then((imgUrl) => {
            // console.log(imgUrl, "blob");
            this.previewImg = imgUrl;
            if (this.listIndex !== null) {
              this.$emit("successCheng", this.previewImg, this.listIndex);
            } else {
              this.$emit("successCheng", this.previewImg);
            }
          });

          this.uploadImage(blob)
        });
      }
    },
    // 上传到后端
    uploadImage(blob) {
      let file = new File([blob], this.fileName, {
        type: "image/png",
        lastModified: Date.now(),
      });
      let fd = new FormData();
      fd.append("file", file);
      insertImage(fd, this.txsmjid).then((res) => {
        // console.log("图片上传", res);
        this.dialogVisible = false;
        if (res.code === 200) {
          this.$modal.msgSuccess("上传成功");
        }
      }).finally(() => {
        this.loading = false;
      });
    },

    // 预览图片
    viewFn() {
      let preIndex = 0;
      this.$viewerApi({
        images: [this.previewImg],
        options: {
          initialViewIndex: preIndex,
        },
      });
    },
    // 删除图片
    deleteFn() {
      this.previewImg = "";
      // this.$emit("deleteCheng");
    },
    // 更换图片
    refreshFn() {
      this.$refs["upload"].$refs["upload-inner"].handleClick();
    },
    // 获取图片高度并修改
    getImgHeight(imgSrc, scaleWidth = 648, scaleHeight = 1152) {
      return new Promise((resolve, reject) => {
        const img = new Image(); // 创建一个img对象
        img.src = imgSrc; // 设置图片地址
        let imgUrl = ""; // 接收图片地址
        img.onload = () => {
          if (img.width > scaleWidth || img.height > scaleHeight) {
            const canvas = document.createElement("canvas");
            const context = canvas.getContext("2d");
            canvas.width = scaleWidth;
            canvas.height = scaleHeight;
            context.drawImage(img, 0, 0, scaleWidth, scaleHeight);
            if (this.cropFormat == "blob") {
              imgUrl = this.base64toBlob(
                canvas.toDataURL("image/png", 1),
                "image/png"
              );
            } else {
              imgUrl = canvas.toDataURL("image/png", 1);
            }
            resolve(imgUrl);
          } else {
            imgUrl = imgSrc;
            resolve(imgUrl);
          }
        };
      });
    },
    // base64转blob
    base64toBlob(base64, type = "application/octet-stream") {
      // 去除base64头部
      const images = base64.replace(/^data:image\/\w+;base64,/, "");
      const bstr = atob(images);
      let n = bstr.length;
      const u8arr = new Uint8Array(n);
      while (n--) {
        u8arr[n] = bstr.charCodeAt(n);
      }
      return URL.createObjectURL(new Blob([u8arr], { type }));
    },
  },
};
</script>

<style lang="scss" scoped>
.preview-img {
  position: relative;
  cursor: pointer;

  &:hover {
    .preview-img-box {
      display: block;
    }
  }
}

.preview-img-box {
  width: 100%;
  height: 100%;
  position: absolute;
  left: 0;
  top: 0;
  background-color: rgba(30, 28, 28, 0.5);
  display: none;
  color: #d9d9d9;
  font-size: 24px;
  text-align: center;
}

.preview-img-number {
  width: 20px;
  height: 20px;
  overflow: hidden;
  position: absolute;
  right: 0;
  bottom: 0;
  background-color: rgba(8, 137, 53, 1);
  color: #d9d9d9;
  font-size: 20px;
  text-align: center;
  border-radius: 50%;
}

.upload-demo {
  border: 1px dashed #d9d9d9;
  border-radius: 6px;
  cursor: pointer;
}

.upload-demo-icon {
  font-size: 60px;
  color: #8c939d;
  text-align: center;
}

.crop-dialog {
  .action-box {
    margin: 20px;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;

    button {
      margin-top: 15px;
      //width: 80px;
      margin-right: 15px;
    }
  }

  .dialog-footer {
    text-align: center;

    button {
      width: 100px;
    }
  }
}
</style>

使用组件:

<template>
    <div>
     <!-- 图片裁剪 -->
     <avatarUpload :key="new Date().getTime()" :imgSrc="imageUrl" :uploadWidth="148" :uploadHeight="207" :actionButtonFlag="true" :objectFit="'cover'" 
class="avatar-uploader" :txsmjid="txsmjid">
    </avatarUpload>
    </div>
</template>
<script>
import avatarUpload from "./avatarUpload.vue";
export default {
  components: {
    avatarUpload,
  },
  data() {
     imageUrl:'',
     txsmjid:'',
  }      
}
</script>