vue3【组件封装】头像裁剪 S-avatar.vue

最终效果

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

技术要点

图片裁剪

安装依赖 vue-cropper

npm install vue-cropper@next

专用于vue3 项目的图片裁剪,详细使用参考官方文档

页面使用

import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
<vue-cropper
  ref="cropper"
  v-bind="option"
  @realTime="realTime"
></vue-cropper>
const cropper = 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: "", // 裁剪图片的地址(可选值:url 地址, base64, blob)
  info: true, // 是否显示裁剪框的宽高信息
  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)
  full: true,
});
const previews = ref<any>({
  url: "",
  file: null,
});
// 实时预览
const realTime = () => {
  cropper.value.getCropBlob((blob: Blob) => {
    previews.value.url = window.URL.createObjectURL(blob);
    previews.value.file = blobToFile(blob, imageName.value);
  });
};

裁剪效果预览

        <div class="preview">
          <img :src="previews.url" />
        </div>
.preview {
  width: 150px;
  height: 150px;
  margin: 0px auto 20px auto;
  border-radius: 50%;
  border: 1px solid #ccc;
  background-color: #ccc;
  overflow: hidden;
}

阻止点击冒泡

@click.stop

组件封装 S-avatar.vue

components/SUI/S-avatar.vue

<template>
  <el-upload
    class="avatar-uploader"
    action="#"
    :show-file-list="false"
    :on-success="handleAvatarSuccess"
    :before-upload="beforeAvatarUpload"
    :accept="imgType"
    :drag="drag"
    :disabled="disabled"
  >
    <S-msgWin :msg="callbackMessage" :duration="500" />
    <div v-if="imageUrl" @click.stop class="avatar-container relative group">
      <el-image
        class="avatar"
        :src="imageUrl"
        fit="cover"
        :preview-src-list="[imageUrl]"
      />
      <div
        v-if="!disabled"
        class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
      >
        <el-icon
          :size="30"
          color="white"
          class="edit-icon text-white mr-8 cursor-pointer"
          @click.stop="handleEditAvatar"
        >
          <Edit />
        </el-icon>

        <el-popconfirm title="确定删除吗?" @confirm="handleDeleteAvatar">
          <template #reference>
            <el-icon
              @click.stop
              :size="30"
              color="white"
              class="delete-icon text-white cursor-pointer"
            >
              <Delete />
            </el-icon>
          </template>
        </el-popconfirm>
      </div>
    </div>
    <el-icon v-else class="avatar-uploader-icon">
      <Plus />
    </el-icon>
  </el-upload>

  <el-dialog title="修改头像" v-model="editAvatarDialog" width="600">
    <el-row type="flex" justify="center" class="nowarp">
      <div class="cropper">
        <vue-cropper
          ref="cropper"
          v-bind="option"
          @realTime="realTime"
        ></vue-cropper>
      </div>
      <div class="previewBox">
        <div class="preview">
          <img :src="previews.url" />
        </div>

        <el-row type="flex" justify="center">
          <el-upload
            action="#"
            :show-file-list="false"
            :on-success="handleAvatarSuccess"
            :before-upload="beforeAvatarUpload"
          >
            <el-button size="small" type="primary"> 更换头像 </el-button>
          </el-upload>
        </el-row>
        <br />
        <el-row>
          <el-button
            :icon="ZoomIn"
            circle
            size="small"
            @click="changeScale(1)"
          ></el-button>
          <el-button
            :icon="ZoomOut"
            circle
            size="small"
            @click="changeScale(-1)"
          ></el-button>
          <el-button
            :icon="Download"
            circle
            size="small"
            @click="downloadPreView"
          ></el-button>
          <el-button
            :icon="RefreshLeft"
            circle
            size="small"
            @click="rotateLeft"
          ></el-button>
          <el-button
            :icon="RefreshRight"
            circle
            size="small"
            @click="rotateRight"
          ></el-button>
        </el-row>
      </div>
    </el-row>

    <template #footer>
      <div class="dialog-footer">
        <el-button @click="editAvatarDialog = false">取 消</el-button>
        <el-button type="primary" @click="editAvatarConfirm">确 定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
import {
  ZoomIn,
  ZoomOut,
  Download,
  RefreshLeft,
  RefreshRight,
} from "@element-plus/icons-vue";
import { ref } from "vue";
import { Plus, Edit, Delete } from "@element-plus/icons-vue";
import type { UploadProps } from "element-plus";

const { imgType, drag, disabled, maxImgSize } = defineProps({
  imgType: {
    type: String,
    default: "image/*",
  },
  drag: {
    type: Boolean,
    default: false,
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  maxImgSize: {
    type: Number,
    default: 2,
  },
});

const imageUrl = defineModel<string>();

const { uploadImage } = useImageUpload();
const editAvatarDialog = ref(false);
const imageName = ref("");

const previews = ref<any>({
  url: "",
  file: null,
});
// 响应式变量
const callbackMessage = useCallbackMessage();
const cropper = 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: "", // 裁剪图片的地址(可选值:url 地址, base64, blob)
  info: true, // 是否显示裁剪框的宽高信息
  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)
  full: true,
});

const beforeAvatarUpload: UploadProps["beforeUpload"] = (rawFile) => {
  if (rawFile.size / 1024 / 1024 > maxImgSize) {
    callbackMessage.value = {
      show: true,
      valid: false,
      content: `图片大小不能超过${maxImgSize}MB!`,
    };
    return false;
  }
  return true;
};

const handleAvatarSuccess: UploadProps["onSuccess"] = (
  response,
  uploadFile
) => {
  // ! 为 TS 的非空断言
  option.value.img = URL.createObjectURL(uploadFile.raw!);
  editAvatarDialog.value = true;
  imageName.value = uploadFile.name;
};

// 实时预览
const realTime = () => {
  cropper.value.getCropBlob((blob: Blob) => {
    previews.value.url = window.URL.createObjectURL(blob);
    previews.value.file = blobToFile(blob, imageName.value);
  });
};

const editAvatarConfirm = async () => {
  editAvatarDialog.value = false;

  const res = await uploadImage(previews.value.file);
  if (Array.isArray(res?.data) && res.data.length) {
    imageUrl.value = res.data[0].url;
    imageName.value = res.data[0].filename;
    callbackMessage.value = {
      show: true,
      valid: true,
      content: `上传成功`,
    };
  } else {
    callbackMessage.value = {
      show: true,
      valid: false,
      content: `上传失败`,
    };
  }
};

const downloadPreView = () => {
  let aLink = document.createElement("a");
  aLink.download = "头像裁剪后的效果图.png";

  cropper.value.getCropBlob((blob: Blob) => {
    aLink.href = window.URL.createObjectURL(blob);
    aLink.click();
  });
};

const rotateLeft = () => {
  cropper.value.rotateLeft();
};

const rotateRight = () => {
  cropper.value.rotateRight();
};

const changeScale = (scaleSize: number) => {
  cropper.value.changeScale(scaleSize);
};

const handleDeleteAvatar = () => {
  if (!imageName.value) {
    imageName.value = imageUrl.value?.split("/").pop() || "";
  }
  $fetch(`/api/upload/delete`, {
    body: { filename: imageName.value },
    method: "POST",
  }).then((res) => {
    callbackMessage.value = {
      show: true,
      valid: true,
      content: `删除成功`,
    };
    imageUrl.value = "";
  });
};

const handleEditAvatar = () => {
  if (imageUrl.value) {
    imageName.value = imageUrl.value.split("/").pop() || "";
  }
  option.value.img = imageUrl.value || "";
  editAvatarDialog.value = true;
};
</script>

<style scoped>
.previewBox {
  text-align: center;
  margin-left: 60px;
}

.preview {
  width: 150px;
  height: 150px;
  margin: 0px auto 20px auto;
  border-radius: 50%;
  border: 1px solid #ccc;
  background-color: #ccc;
  overflow: hidden;
}

.cropper {
  width: 260px;
  height: 260px;
}

.avatar-uploader .avatar {
  width: 178px;
  height: 178px;
  display: block;
}

.avatar-container {
  position: relative;
}

.avatar:hover + .avatar-actions,
.avatar-actions:hover {
  display: flex;
}
</style>

<style>
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 50% !important;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 178px;
  height: 178px;
  text-align: center;
}
</style>

相关组件

components/SUI/S-msgWin.vue

<script lang="ts" setup>
const props = defineProps({
  msg: {
    type: Object,
    required: true,
  },
  top: {
    type: String,
    default: "50%",
  },
  duration: {
    type: Number,
    default: 3000,
  },
});
watch(
  () => props.msg,
  (newVal, oldVal) => {
    if (newVal.show) {
      setTimeout(() => {
        props.msg.show = false;
      }, props.duration);
    }
  }
);
</script>
<template>
  <transition name="fade">
    <div
      v-show="msg.show"
      class="msgBox"
      :class="{
        'border-#fde2e2 bg-red-50 text-#f56c6c': !msg.valid,
        'border-green-800 bg-green-50 text-green-500': msg.valid,
      }"
    >
      <S-icon :icon="msg.valid ? 'ep:success-filled' : 'ix:error-filled'" />
      <div class="whitespace-nowrap">{{ msg.content }}</div>
      <S-icon
        v-if="msg.closeable"
        class="c-#a8abb2 cursor-pointer"
        icon="material-symbols:close-rounded"
        @click="msg.show = false"
      />
    </div>
  </transition>
</template>
<style scoped>
.msgBox {
  font-size: 14px;
  position: absolute;
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 6px;
  padding: 8px 10px;
  top: v-bind(props.top);
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 9999;
  border-radius: 4px;
}

.fade-leave-from,
.fade-enter-to {
  opacity: 1;
}
.fade-leave-to,
.fade-enter-from {
  opacity: 0;
}
/* 定义过渡的持续时间和动画效果 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
</style>

相关组合式函数

composables/useCallbackMessage.ts

export const useCallbackMessage = () => {
  const callbackMessage = ref({
    show: false,
    valid: true,
    content: "",
  });

  return callbackMessage;
};

composables/useImageUpload.ts

export const useImageUpload = () => {
  const isLoading = ref(false);
  const error = ref<string | null>(null);

  const uploadImage = async (file: File) => {
    isLoading.value = true;
    error.value = null;

    try {
      const formData = new FormData();
      formData.append("image", file);

      const response = await $fetch("/api/upload/image", {
        method: "POST",
        body: formData,
        headers: {
          Accept: "application/json",
        },
      });

      return response;
    } catch (err: any) {
      error.value = err.message || "上传失败,请重试";
      throw err;
    } finally {
      isLoading.value = false;
    }
  };

  return {
    uploadImage,
    isLoading,
    error,
  };
};

页面使用

      <S-avatar
        :disabled="action === 'detail'"
        v-model="formData.avatar"
      />

Nuxt 中使用

因 vue-cropper 不支持服务端渲染,所以必须限定其仅在客户端渲染

import { ref, onMounted } from "vue";
import { defineAsyncComponent } from "vue";

// 标记客户端环境
const isClient = ref(false);

// 动态导入组件,禁用SSR
const AvatarCropper = defineAsyncComponent({
  loader: () => import("~/components/SUI/S-avatar.vue"),
  suspensible: false, // 关键:禁止在服务端渲染该组件,使用 suspensible 替代 ssr
});

onMounted(() => {
  isClient.value = true; // 确保在客户端挂载后才显示组件
});
      <AvatarCropper
        :disabled="action === 'detail'"
        v-if="isClient"
        v-model="formData.avatar"
      />
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

朝阳39

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值