vue3实战九、vue3+vue-cropper实现头像修改

vue3实战九、vue3+vue-cropper实现头像修改

效果

实现步骤

第一步、安装vue-cropper

javascript 复制代码
npm install vue-cropper@next -d --save

第二步、组件引入vue-cropper

javascript 复制代码
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";

第三步、实现点击修改弹窗布局

使用img环境elementPlus的el-dialog弹窗组件,分为两个区域,一个是裁剪区,一个是结果区域,底部添加向左旋转,向右旋转,放大,缩小的功能。

javascript 复制代码
<template>
  <div>
    <!-- 点击头像区域打开裁剪器 -->
    <div class="user-info-head" @click="editCropper">
      <img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
    </div>

    <!-- 裁剪弹窗 -->
    <el-dialog
      v-model="open"
      :title="title"
      width="800px"
      append-to-body
      @opened="modalOpened"
      @close="closeDialog"
    >
      <el-row>
        <!-- 裁剪区域 -->
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <vue-cropper
            ref="cropperRef"
            :img="options.img"
            :info="true"
            :auto-crop="options.autoCrop"
            :auto-crop-width="options.autoCropWidth"
            :auto-crop-height="options.autoCropHeight"
            :fixed-box="options.fixedBox"
            :output-type="options.outputType"
            @real-time="realTime"
            v-if="visible"
          />
        </el-col>
        <!-- 预览区域 -->
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <div class="avatar-upload-preview">
            <img :src="previews.url" :style="previews.img" />
          </div>
        </el-col>
      </el-row>

      <br />

      <!-- 操作按钮 -->
      <el-row>
        <!-- 选择图片 -->
        <el-col :lg="2" :sm="3" :xs="3">
          <el-upload
            action="#"
            :http-request="requestUpload"
            :show-file-list="false"
            :before-upload="beforeUpload"
          >
            <el-button size="small">
              选择
              <SvgIcon name="ele-UploadFilled" size="small"></SvgIcon>
              <!-- <el-icon class="el-icon--right"><Upload /></el-icon> -->
            </el-button>
          </el-upload>
        </el-col>

        <!-- 缩放 -->
        <el-col :lg="{ span: 1, offset: 2 }" :sm="2" :xs="2">
          <el-button
            style="text-align: center"
            size="small"
            @click="changeScale(1)"
          >
            <SvgIcon size="small" name="ele-Plus"></SvgIcon>
          </el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
          <el-button
            style="text-align: center"
            size="small"
            @click="changeScale(-1)"
            ><SvgIcon size="small" name="ele-Minus"></SvgIcon
          ></el-button>
        </el-col>

        <!-- 旋转 -->
        <el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
          <el-button size="small" style="text-align: center" @click="rotateLeft"
            ><SvgIcon size="small" name="ele-RefreshLeft"></SvgIcon
          ></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
          <el-button
            size="small"
            style="text-align: center"
            @click="rotateRight"
            ><SvgIcon size="small" name="ele-RefreshRight"></SvgIcon
          ></el-button>
        </el-col>

        <!-- 提交 -->
        <el-col :lg="{ span: 2, offset: 6 }" :sm="2" :xs="2">
          <el-button type="primary" size="small" @click="uploadImg"
            >提 交</el-button
          >
        </el-col>
      </el-row>
    </el-dialog>
  </div>
</template>

第四步、实现图片裁剪及上传逻辑

  1. 导入必要的模块和依赖 首先导入了Vue 3 的基本钩子函数(如 ref, reactive, 生命周期钩子等)以及 vue-cropper 插件相关的资源,引入了项目中使用的状态管理库 Piniastore 模块 (useAuthStore)。使用了防抖函数 debounce 来优化 resize 事件的处理。引入了 Element Plus UI 库中的图标组件 Upload

    javascript 复制代码
    mport { ref, reactive, onMounted, onBeforeUnmount } from "vue";
    import "vue-cropper/dist/index.css";
    import { VueCropper } from "vue-cropper";
    // 假设你使用 Pinia 并已创建了 authStore
    import { useAuthStore } from "../../stores/auth";
    // import { uploadAvatar } from "@/api/system/user";
    import { debounce } from "@/utils/index.js";
    // Element Plus 图标 (用于 Upload 按钮)
    import { Upload } from "@element-plus/icons-vue";
  2. 定义 PropsRefs 定义了一个 Prop user,用于接收用户信息对象,默认为空对象,使用 refreactive 创建响应式变量来控制弹窗显示、裁剪器配置选项、预览数据等。

    javascript 复制代码
    const props = defineProps({
      user: {
        type: Object,
        default: () => ({}),
      },
    });
    
    const authStore = useAuthStore();
    const open = ref(false); // 控制弹窗显示
    const visible = ref(false); // 控制 vue-cropper 组件的显示 (解决 resize 问题)
    const cropperRef = ref(null); // 引用 vue-cropper 组件
    const resizeHandler = ref(null); // 存储防抖后的 resize 事件处理器
    const title = "修改头像";
  3. 初始化裁剪器配置 options 对象初始化了裁剪器的基本设置,包括图片来源、自动裁剪尺寸、固定裁剪框等。

javascript 复制代码
const options = reactive({
  img: authStore.userInfo?.imageUrl || "", // 使用 Pinia store 的 userInfo.imageUrl
  autoCrop: true,
  autoCropWidth: 200,
  autoCropHeight: 200,
  fixedBox: true,
  outputType: "png",
});
const previews = ref({}); // 预览数据
  1. 实现功能方法
  • editCropper: 方法用于打开编辑头像的弹窗。
  • modalOpened:方法在弹窗打开时调用,添加窗口大小调整监听器以刷新裁剪器。
  • 提供了几个方法用于操作裁剪器:refresh 刷新裁剪器,rotateLeft 向左旋转图片,rotateRight 向右旋转图片,changeScale 调整图片缩放比例。
  • beforeUpload:方法用于验证上传文件格式,并将选择的图片转换为 Base64 格式以便在裁剪器中显示。
  • uploadImg: 方法负责获取裁剪后的图片Blob 数据并通过表单提交更新用户的头像(注释部分显示了如何与后端 API 进行交互,但实际代码被注释掉了)。
  • realTime:方法用于实时预览裁剪效果。
  • closeDialog:方法在关闭对话框时恢复原始头像并清理resize事件监听器。
javascript 复制代码
<script setup>
// 编辑头像 - 打开弹窗
const editCropper = () => {
  open.value = true;
};
// 弹窗打开后回调
const modalOpened = () => {
  visible.value = true;
  // 添加 resize 事件监听器 (带防抖)
  if (!resizeHandler.value) {
    resizeHandler.value = debounce(() => {
      refresh();
    }, 100);
  }
  window.addEventListener("resize", resizeHandler.value);
};

// 刷新裁剪器
const refresh = () => {
  cropperRef.value?.refresh();
};
// 覆盖默认上传行为 (空函数)
const requestUpload = () => {
  // do nothing, use beforeUpload instead
};
// 向左旋转
const rotateLeft = () => {
  cropperRef.value?.rotateLeft();
};
// 向右旋转
const rotateRight = () => {
  cropperRef.value?.rotateRight();
};
// 图片缩放
const changeScale = (num = 1) => {
  cropperRef.value?.changeScale(num);
};

// 上传前处理
const beforeUpload = (file) => {
  if (!file.type.startsWith("image/")) {
    ElMessage.error("文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。");
    return false; // 阻止上传
  }

  const reader = new FileReader();
  reader.readAsDataURL(file);
  reader.onload = () => {
    options.img = reader.result;
  };
  return false; // 阻止 el-upload 的默认上传,使用 requestUpload 或这里处理
};

// 上传图片
const uploadImg = () => {
  cropperRef.value?.getCropBlob(async (data) => {
    const formData = new FormData();
    formData.append("avatarfile", data, "avatar.png"); // 添加文件名和类型

    // try {
    //   const response = await uploadAvatar(formData);
    //   // 假设 response 包含新的图片 URL
    //   const newImageUrl = response.imgUrl; // 或 response.data.imgUrl, 根据你的 API 响应结构调整
    //   // 更新本地状态和 Store
    //   open.value = false;
    //   options.img = import.meta.env.VITE_APP_BASE_API + newImageUrl; // 使用 Vite 环境变量
    //   // 假设你的 authStore 有一个 action 来更新用户信息或头像
    //   // 方式1: 直接更新 imageUrl (如果 store 允许)
    //   // authStore.userInfo.imageUrl = options.img
    //   // 方式2: 调用一个 action 来更新 (推荐)
    //   await authStore.updateUserInfo({ imageUrl: options.img }); // 假设有一个 updateUserInfo action
    //   ElMessage.success("修改成功");
    //   visible.value = false;
    // } catch (error) {
    //   console.error("上传头像失败:", error);
    //   ElMessage.error("修改失败");
    //   // 可选: 根据需要处理错误
    // }
  });
};

// 实时预览
const realTime = (data) => {
  previews.value = data;
};

// 关闭弹窗
const closeDialog = () => {
  // 关闭时恢复原始头像 (如果上传未完成或取消)
  options.img = authStore.userInfo?.imageUrl || "";
  visible.value = false;
  // 移除 resize 事件监听器
  if (resizeHandler.value) {
    window.removeEventListener("resize", resizeHandler.value);
    resizeHandler.value = null;
  }
};
</script>

第五步、房东方法处理

javascript 复制代码
/**
 * @param {Function} func
 * @param {number} wait
 * @param {boolean} immediate
 * @return {*}
 */
export function debounce(func, wait, immediate) {
  let timeout, args, context, timestamp, result

  const later = function () {
    // 据上一次触发时间间隔
    const last = +new Date() - timestamp

    // 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
    if (last < wait && last > 0) {
      timeout = setTimeout(later, wait - last)
    } else {
      timeout = null
      // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
      if (!immediate) {
        result = func.apply(context, args)
        if (!timeout) context = args = null
      }
    }
  }

  return function (...args) {
    context = this
    timestamp = +new Date()
    const callNow = immediate && !timeout
    // 如果延时不存在,重新设定延时
    if (!timeout) timeout = setTimeout(later, wait)
    if (callNow) {
      result = func.apply(context, args)
      context = args = null
    }

    return result
  }
}

第六步、调用方式

javascript 复制代码
  <AvatarCropper />
const AvatarCropper = defineAsyncComponent(
  () => import("../../components/userAvatar/index.vue")
);

第七步、查看效果

总体代码:

javascript 复制代码
<template>
  <div>
    <!-- 点击头像区域打开裁剪器 -->
    <div class="user-info-head" @click="editCropper">
      <img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
    </div>

    <!-- 裁剪弹窗 -->
    <el-dialog
      v-model="open"
      :title="title"
      width="800px"
      append-to-body
      @opened="modalOpened"
      @close="closeDialog"
    >
      <el-row>
        <!-- 裁剪区域 -->
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <vue-cropper
            ref="cropperRef"
            :img="options.img"
            :info="true"
            :auto-crop="options.autoCrop"
            :auto-crop-width="options.autoCropWidth"
            :auto-crop-height="options.autoCropHeight"
            :fixed-box="options.fixedBox"
            :output-type="options.outputType"
            @real-time="realTime"
            v-if="visible"
          />
        </el-col>
        <!-- 预览区域 -->
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <div class="avatar-upload-preview">
            <img :src="previews.url" :style="previews.img" />
          </div>
        </el-col>
      </el-row>

      <br />

      <!-- 操作按钮 -->
      <el-row>
        <!-- 选择图片 -->
        <el-col :lg="2" :sm="3" :xs="3">
          <el-upload
            action="#"
            :http-request="requestUpload"
            :show-file-list="false"
            :before-upload="beforeUpload"
          >
            <el-button size="small">
              选择
              <SvgIcon name="ele-UploadFilled" size="small"></SvgIcon>
              <!-- <el-icon class="el-icon--right"><Upload /></el-icon> -->
            </el-button>
          </el-upload>
        </el-col>

        <!-- 缩放 -->
        <el-col :lg="{ span: 1, offset: 2 }" :sm="2" :xs="2">
          <el-button
            style="text-align: center"
            size="small"
            @click="changeScale(1)"
          >
            <SvgIcon size="small" name="ele-Plus"></SvgIcon>
          </el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
          <el-button
            style="text-align: center"
            size="small"
            @click="changeScale(-1)"
            ><SvgIcon size="small" name="ele-Minus"></SvgIcon
          ></el-button>
        </el-col>

        <!-- 旋转 -->
        <el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
          <el-button size="small" style="text-align: center" @click="rotateLeft"
            ><SvgIcon size="small" name="ele-RefreshLeft"></SvgIcon
          ></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :sm="2" :xs="2">
          <el-button
            size="small"
            style="text-align: center"
            @click="rotateRight"
            ><SvgIcon size="small" name="ele-RefreshRight"></SvgIcon
          ></el-button>
        </el-col>

        <!-- 提交 -->
        <el-col :lg="{ span: 2, offset: 6 }" :sm="2" :xs="2">
          <el-button type="primary" size="small" @click="uploadImg"
            >提 交</el-button
          >
        </el-col>
      </el-row>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from "vue";
import "vue-cropper/dist/index.css";
import { VueCropper } from "vue-cropper";
// 假设你使用 Pinia 并已创建了 authStore

import { useAuthStore } from "../../stores/auth";
// import { uploadAvatar } from "@/api/system/user";
import { debounce } from "@/utils/index.js";
// Element Plus 图标 (用于 Upload 按钮)
import { Upload } from "@element-plus/icons-vue";

// --- Props ---
const props = defineProps({
  user: {
    type: Object,
    default: () => ({}),
  },
});

const authStore = useAuthStore();
const open = ref(false); // 控制弹窗显示
const visible = ref(false); // 控制 vue-cropper 组件的显示 (解决 resize 问题)
const cropperRef = ref(null); // 引用 vue-cropper 组件
const resizeHandler = ref(null); // 存储防抖后的 resize 事件处理器

const title = "修改头像";
const options = reactive({
  img: authStore.userInfo?.imageUrl || "", // 使用 Pinia store 的 userInfo.imageUrl
  autoCrop: true,
  autoCropWidth: 200,
  autoCropHeight: 200,
  fixedBox: true,
  outputType: "png",
});
const previews = ref({}); // 预览数据
// 编辑头像 - 打开弹窗
const editCropper = () => {
  open.value = true;
};

// 弹窗打开后回调
const modalOpened = () => {
  visible.value = true;
  // 添加 resize 事件监听器 (带防抖)
  if (!resizeHandler.value) {
    resizeHandler.value = debounce(() => {
      refresh();
    }, 100);
  }
  window.addEventListener("resize", resizeHandler.value);
};

// 刷新裁剪器
const refresh = () => {
  cropperRef.value?.refresh();
};
// 覆盖默认上传行为 (空函数)
const requestUpload = () => {
  // do nothing, use beforeUpload instead
};
// 向左旋转
const rotateLeft = () => {
  cropperRef.value?.rotateLeft();
};
// 向右旋转
const rotateRight = () => {
  cropperRef.value?.rotateRight();
};
// 图片缩放
const changeScale = (num = 1) => {
  cropperRef.value?.changeScale(num);
};

// 上传前处理
const beforeUpload = (file) => {
  if (!file.type.startsWith("image/")) {
    ElMessage.error("文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。");
    return false; // 阻止上传
  }

  const reader = new FileReader();
  reader.readAsDataURL(file);
  reader.onload = () => {
    options.img = reader.result;
  };
  return false; // 阻止 el-upload 的默认上传,使用 requestUpload 或这里处理
};

// 上传图片
const uploadImg = () => {
  cropperRef.value?.getCropBlob(async (data) => {
    const formData = new FormData();
    formData.append("avatarfile", data, "avatar.png"); // 添加文件名和类型

    // try {
    //   const response = await uploadAvatar(formData);
    //   // 假设 response 包含新的图片 URL
    //   const newImageUrl = response.imgUrl; // 或 response.data.imgUrl, 根据你的 API 响应结构调整
    //   // 更新本地状态和 Store
    //   open.value = false;
    //   options.img = import.meta.env.VITE_APP_BASE_API + newImageUrl; // 使用 Vite 环境变量
    //   // 假设你的 authStore 有一个 action 来更新用户信息或头像
    //   // 方式1: 直接更新 imageUrl (如果 store 允许)
    //   // authStore.userInfo.imageUrl = options.img
    //   // 方式2: 调用一个 action 来更新 (推荐)
    //   await authStore.updateUserInfo({ imageUrl: options.img }); // 假设有一个 updateUserInfo action
    //   ElMessage.success("修改成功");
    //   visible.value = false;
    // } catch (error) {
    //   console.error("上传头像失败:", error);
    //   ElMessage.error("修改失败");
    //   // 可选: 根据需要处理错误
    // }
  });
};

// 实时预览
const realTime = (data) => {
  previews.value = data;
};

// 关闭弹窗
const closeDialog = () => {
  // 关闭时恢复原始头像 (如果上传未完成或取消)
  options.img = authStore.userInfo?.imageUrl || "";
  visible.value = false;
  // 移除 resize 事件监听器
  if (resizeHandler.value) {
    window.removeEventListener("resize", resizeHandler.value);
    resizeHandler.value = null;
  }
};
</script>

<style scoped lang="scss">
.user-info-head {
  position: relative;
  display: inline-block;
  height: 40px;
  text-align: center;
}

.user-info-head:hover:after {
  content: "+";
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  color: #eee;
  background: rgba(0, 0, 0, 0.5);
  font-size: 24px;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  cursor: pointer;
  margin: 0px auto;
  line-height: 40px;
  border-radius: 50%;
}

/* image */
.img-circle {
  border-radius: 50%;
}

.img-lg {
  width: 40px;
  height: 40px;
}

.avatar-upload-preview {
  position: relative;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 200px;
  height: 200px;
  border-radius: 50%;
  box-shadow: 0 0 4px #ccc;
  overflow: hidden;
}
</style>

防抖代码:

javascript 复制代码
/**
 * @param {Function} func
 * @param {number} wait
 * @param {boolean} immediate
 * @return {*}
 */
export function debounce(func, wait, immediate) {
  let timeout, args, context, timestamp, result

  const later = function () {
    // 据上一次触发时间间隔
    const last = +new Date() - timestamp

    // 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
    if (last < wait && last > 0) {
      timeout = setTimeout(later, wait - last)
    } else {
      timeout = null
      // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
      if (!immediate) {
        result = func.apply(context, args)
        if (!timeout) context = args = null
      }
    }
  }
  return function (...args) {
    context = this
    timestamp = +new Date()
    const callNow = immediate && !timeout
    // 如果延时不存在,重新设定延时
    if (!timeout) timeout = setTimeout(later, wait)
    if (callNow) {
      result = func.apply(context, args)
      context = args = null
    }

    return result
  }
}
相关推荐
程序猿阿伟6 分钟前
《不只是接口:GraphQL与RESTful的本质差异》
前端·restful·graphql
若梦plus2 小时前
Nuxt.js基础与进阶
前端·vue.js
樱花开了几轉2 小时前
React中为甚么强调props的不可变性
前端·javascript·react.js
风清云淡_A2 小时前
【REACT18.x】CRA+TS+ANTD5.X实现useImperativeHandle让父组件修改子组件的数据
前端·react.js
小飞大王6662 小时前
React与Rudex的合奏
前端·react.js·前端框架
若梦plus2 小时前
React之react-dom中的dom-server与dom-client
前端·react.js
若梦plus2 小时前
react-router-dom中的几种路由详解
前端·react.js
若梦plus2 小时前
Vue服务端渲染
前端·vue.js
Mr...Gan2 小时前
VUE3(四)、组件通信
前端·javascript·vue.js
OEC小胖胖2 小时前
渲染篇(二):解密Diff算法:如何用“最少的操作”更新UI
前端·算法·ui·状态模式·web