vue移动端实现头像裁剪上传

一、效果展示

首页效果

点击更换头像进入裁剪页面

点击保存上传图片

上传完成后的效果

二、代码实现

本项目用到了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>
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值