vue+Nodejs+Koa搭建前后端系统(十)--实战之更新用户信息

9 篇文章 0 订阅

本篇为实战篇,干货不多。

基于上篇实现的上传图片接口,可以实现下更新用户信息功能,实现后的前端效果如下图:

在这里插入图片描述

前端

顶部区域

在这里插入图片描述

代码如下:

<!-- /src/pages/top-header/top-header.vue -->
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import http, { httpHost } from "@/http";//httpHost-服务器地址
import { ElMessage } from "element-plus";
import { useUserStore } from "@/store/index";
import { EditPen } from "@element-plus/icons-vue";
import avatarDefault from "../../assets/Images/avatar.png";//默认头像
import LinkGroup from "./components/link-group.vue";//头像下拉弹窗中链接列表组件
import { navItem } from "./top-header.data";//头像下拉弹窗中链接列表数据

const userStore = useUserStore();//Pinia仓储
const popoverShow = ref(false);;//头像下拉弹窗显隐
const userInfo = ref<any>({});//用户信息
/** 获取用户信息 */
const getUserInfo = () => {
  http.get("users/userInfo", { username: userStore.userName }).then((res: any) => {
      const { code, data, message } = res;
      if (code === 200) {
      	//将用户信息存储到Pinia仓储
        userStore.setUserInfo({ ...data, avatar: data.avatar });
      }
    }).catch((err: any) => {
      ElMessage({ message: err.message, type: "error" });
    });
};
/** 监听Pinia仓储的userInfo变量,使其即时更新到本页面 */
watch(() => userStore.userInfo, (newValue: any) => {
    if (newValue) userInfo.value = newValue;
});
getUserInfo();
</script>
<template>
  <div class="top-header">
    <div class="logo"></div>
    <div class="setting">
    	<!--头像区域 START -->
      <div class="avatar-wrap" @mouseenter="popoverShow = true" @mouseleave="popoverShow = false">
        <el-popover :visible="popoverShow" :show-arrow="false" :teleported="false" placement="bottom" :offset="0" :width="260">
          <template #reference>
            <el-avatar class="avatar" :size="40" :src="userInfo.avatar?httpHost+userInfo.avatar.replace(/^\//, ''):''">
              <img :src="avatarDefault" />
            </el-avatar>
          </template>
          <!-- 头像下拉弹窗 START -->
          <template #default>
            <div class="my-top-item male">
              <div class="username-line">
              	<!-- 显示昵称或用户名 -->
                <span class="username">{{userInfo.nickname || userInfo.username}}</span>
                <!-- 女性图标 -->
                <el-icon v-if="userInfo.sex == '0'" class="iconfont icon-gaogenxie"></el-icon>
                <!-- 男性图标 -->
                <el-icon v-else-if="userInfo.sex == '1'" class="iconfont icon-hudiejie"></el-icon>
              </div>
              <!-- 签名 -->
              <div class="signature">
                <el-icon><EditPen /></el-icon>
                {{userInfo.signature || `${{ "0": "她", "1": "他" }[userInfo.sex]}啥也没写呀`}}
              </div>
            </div>
            <el-divider />
            <!-- 头像下拉弹窗中链接列表组件 -->
            <LinkGroup :list="navItem" />
          </template>
          <!-- 头像下拉弹窗 END -->
        </el-popover>
      </div>
      <!--头像区域 END -->
      <span>消息</span>
      <span>日报</span>
    </div>
  </div>
</template>
<style lang="less" scoped>
.top-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-inline: 30px;
  height: inherit;
  .logo {
    width: 220px;
    height: 100%;
    background-image: url(../../assets/Images/logo.png);
    background-size: contain;
    background-repeat: no-repeat;
  }
  .setting {
    display: flex;
    justify-content: flex-start;
    align-items: center;
    & > * {
      cursor: pointer;
      transition: all 0.2s;
      margin-left: 15px;
      user-select: none;
      &:hover {
        transform: translateY(2px) scale(1.02);
      }
      &:active {
        transform: translateY(0px) scale(1);
      }
    }
    .avatar-wrap {
      @avatarSize: 40px;
      width: @avatarSize;
      height: @avatarSize;
      border-radius: (@avatarSize / 2);
      .avatar {
        transition: all 0.5s;
      }
      &:hover {
        transform: none;
      }
      &:hover .avatar {
        position: relative;
        z-index: 3001;
        transform: translateY(70%) scale(1.2);
      }
      .my-top-item {
        margin-top: 25px;
        &.female {
          color: #ff3aca;
        }
        &.male {
          color: #38a6f7;
        }
        .username-line {
          text-align: center;
          vertical-align: text-bottom;
          & > *:not(:first-child) {
            margin-left: 6px;
          }
          .username {
            font-size: 20px;
          }
        }
        .signature {
          font-size: 12px;
          text-align: center;
        }
      }
    }
  }
}
</style>

1.修改http.ts文件代码:

/** /src/http.ts 其他代码不变,只是新增了一个httpHost变量,并导出 */
const httpHost = 'http://localhost:5152/';//服务器地址
/** 这里代码不变 */
const getToken = async (secret_key: string) => {
   /** 此处省略 */
};
export { getToken, httpHost }
export default http

2.修改Pinia仓储

/** /src/store/index.ts 仓储中新增userInfo,用于存储用户信息 */
import { defineStore } from "pinia"

export const useUserStore = defineStore('user', {
    state: () => ({
        userName: "",
        userInfo: {}//存储用户信息的变量
    }),
    actions: {
        setUserName(username: string) {
            this.userName = username;
            window.localStorage.setItem("username", username);
        },
        /** 更新仓储中的用户信息 */
        setUserInfo(v = {}) {
            this.userInfo = { ...this.userInfo, ...v }
        }
    }
})

3.头像下拉弹窗中链接列表数据

/** /src/pages/top-header/top-header.data.ts */
import { navItemType } from "./top-header.types"//数据接口信息
import { Unlock } from "@element-plus/icons-vue";
import AddressBook from "./components/address-book.vue";//测试组件,用来测试LinkGroup组件功能
export const navItem: navItemType[] = [
    { title: '个人信息', icon: 'iconfont icon-jia', route: '/user-info/edit' },
    { title: '三五好友', icon: Unlock, modal: AddressBook, modalType: 1, modalTitle: '面烩菜' },
    { title: 'xxx', icon: Unlock, modal: AddressBook, modalType: 2, modalTitle: '面烩菜2' },
]
/** /src/pages/top-header/top-header.types.ts */
import { DefineComponent } from "vue";
import { RouteLocationRaw } from "vue-router"
type ComponentType = DefineComponent<{}, {}, any>

export interface navItemType {
    title: string;//名称
    icon?: string | ComponentType;//图标,可以是iconfont,也可以是组件
    route?: RouteLocationRaw;//如果有值,则点击跳转路由,其为router.push()的参数
    modal?: ComponentType;//弹窗显示的组件,如果有值,则点击显示弹窗
    modalType?: 1 | 2;//弹窗样式 1-设置型弹窗 2-详情型弹窗
    modalTitle?:string;//弹窗标题
    modalWidth?:string;//弹窗宽度
}

顶部用户头像下拉弹窗区域

在这里插入图片描述

<!-- /src/pages/top-header/components/link-group.vue 头像下拉弹窗组件 -->
<script setup lang="ts">
import { reactive, ref, shallowRef } from "vue";
import { useRouter } from "vue-router";
import { navItemType } from "../top-header.types";
import { ElMessage, ElMessageBox } from "element-plus";
import { SwitchButton } from "@element-plus/icons-vue";
import MyDialogDetail from "@/components/my-dialog-detail.vue";//详情型弹窗组件
import MyDialogSet from "@/components/my-dialog-set.vue";//设置型弹窗组件

//组件属性
interface Props {
  list: navItemType[];//头像下拉弹窗中链接列表数据
}
interface successInfoTypes {
  tip?: boolean; //成功提示
  message?: string; //提示文字
  closeAfter?: number; //成功提示后多长时间关闭弹窗,单位ms
}
const props = withDefaults(defineProps<Props>(), {
  list: () => [],
});
const router = useRouter();
//弹窗内容组件,用于component组件is
const modal = shallowRef(null);
//弹窗内容组件Ref
const componentRef = ref(null);
//弹窗信息
const modalInfo = reactive({
  type: 1,
  title: "",
  width: "25%",
});
//弹窗样式 1-设置型弹窗 2-详情型弹窗
const modalType = ref(1);
//弹窗数据是否加载中
const modalLoading = ref(false);
//弹窗显隐
const show = ref(false);
/** 设置弹窗数据 */
const setModal = (item: navItemType) => {
  modal.value = item.modal;
  modalInfo.type = item.modalType;
  modalInfo.title = item.modalTitle || "";
  !!item.modalWidth && (modalInfo.width = item.modalWidth);
};
/** 点击链接事件 */
const chooseLink = (item: navItemType) => {
  //如果该链接有route属性,则跳转路由
  if (item.route) {
    router.push(item.route);
    return;
  }
  //否则弹出弹窗
  setModal(item);
  show.value = true;
};
/** 关闭弹窗 */
const close = () => {
  modalLoading.value = false;
};
/** 点击弹窗取消按钮 如果相应组件中定义了cancel方法,则执行 */
const cancel = (item: navItemType) => {
  if (typeof componentRef.value?.cancel === "function") componentRef.value.cancel();
};
/** 点击确定按钮 需要在相应组件定义ok方法 该方法需是异步方法 */
const ok = async () => {
  //若相应组件定义了ok方法
  if (typeof componentRef.value?.ok === "function") {
    modalLoading.value = true;//变更弹窗为加载中的状态
    try {
      //执行相应组件中的ok方法,并将其ok的resolve状态的存储到变量s中
      const s: successInfoTypes | string = await componentRef.value.ok();
      //ok方法执行成功后弹窗行为默认的设置项
      const defaultSet: successInfoTypes = {
        tip: true,//ok方法成功后是否提示
        message: "成功!",//提示文字
        closeAfter: 900,//几毫秒关闭弹窗
      };
      //ok方法执行成功后弹窗行为的设置项(合并默认值)
      let successInfo: successInfoTypes = {};
      if (typeof s === "string") {
        successInfo = Object.assign({ ...defaultSet }, { message: s });
      } else {
        successInfo = Object.assign({ ...defaultSet }, s);
      }
      //判断ok方法执行成功后是否提示
      successInfo.tip && ElMessage({ message: successInfo.message, type: "success" });
      //几毫秒后关闭弹窗
      setTimeout(() => {
        modalLoading.value = false;
        show.value = false;
      }, successInfo.closeAfter);
    } catch (e: string) {
      modalLoading.value = false;
      ElMessage({ message: e, type: "warning" });
    }
  } else {//若相应组件没有定义ok方法,则提示
    ElMessage({ message: "请定义ok方法", type: "warning" });
  }
};
/** 退出登录方法 */
const logout = () => {
  ElMessageBox.confirm("你确定要退出登录?", "Warning", {
    confirmButtonText: "退出",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    window.localStorage.removeItem("secret_key");
    window.localStorage.removeItem("token");
    window.localStorage.removeItem("username");
    window.location.href = `/#/login`;
  });
};
</script>
<template>
  <div class="link-group__component">
    <ul class="link-group">
      <!--链接列表-->
      <li class="link-item" v-for="i in props.list" :key="i.title" @click="chooseLink(i)">
        <el-icon>
          <i v-if="typeof i.icon === 'string'" :class="i.icon"></i>
          <component v-else :is="i.icon"></component>
        </el-icon>
        {{ i.title }}
      </li>
      <li class="link-item" @click="logout">
        <el-icon><SwitchButton /></el-icon>
        退出登录
      </li>
    </ul>
    <!--弹窗组件 1-设置型弹窗 2-详情型弹窗 -->
    <component
      v-if="modalInfo.type"
      :loading="modalLoading"
      :is="modalInfo.type == 1 ? MyDialogSet : MyDialogDetail"
      modal-class="link-group__dialog"
      @cancel="cancel"
      @ok="ok"
      @close="close"
      draggable
      :title="modalInfo.title"
      v-model="show"
      :width="modalInfo.width">
      <!--弹窗内容组件-->
      <component :is="modal" ref="componentRef"></component>
    </component>
  </div>
</template>
<style lang="less" scoped>
.link-group__component {
  .link-group {
    list-style-type: none;
    padding-inline-start: 0;
    .link-item {
      line-height: 2.2em;
      padding-inline-start: calc(5px + var(--el-popover-padding));
      margin-inline: calc(0px - var(--el-popover-padding));
      &:hover {
        background: #b3dcfa;
        color: #fff;
      }
      &:active {
        transform: translateY(2px) scale(0.99);
      }
    }
  }
}
</style>

自定义弹窗组件

先写一个通用的弹窗组件,有利于弹窗行为的统一性,本组件采用了animate动画,所以需要引入一下animate.css

1.安装animate.css

npm install animate.css --save

2.引入animate.css

/** /src/main.ts */
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router/index'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import VueCookies from 'vue-cookies'
import { createPinia } from "pinia"
import "./assets/iconfont/iconfont.css"
import 'animate.css';//引入animate动画样式
import 'default-passive-events'//解决谷歌浏览器中vue-cropper组件(该组件后面会用到)报错的问题(该错误不影响程序,作为开发者有一点精神洁癖,尽可能看不到朱批大字)

const pinia = createPinia();

createApp(App).use(router).use(ElementPlus).use(VueCookies).use(pinia).mount('#app')

3.编写通用的弹窗组件

<!-- /src/components/my-dialog.vue -->
<!--setup写法禁止属性透传:单独添加一个script标签,暴露一个配置对象-->
<script lang="ts">
export default {
  inheritAttrs: false, // 禁用
};
</script>
<script setup lang="ts">
import { ref, useAttrs, useSlots, withDefaults } from "vue";
interface Props {
  animation?: boolean;
  animationDuration?: number;
  animationOpenClass?: string;
  animationCloseClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
  animation: true, // 是否开启动画,默认开启
  animationOpenClass: "animate__slideInRight", //dialog打开动画class,动画样式使用animate.css
  animationCloseClass: "animate__slideOutLeft", //dialog关闭动画class,动画样式使用animate.css
});
const dialogRef = ref();
/** 这里用Proxy代理监听本组件属性的读取
* 如果读取的属性名称是modal-class 并且有值,则处理返回的属性值
* 否则不做处理,原值返回
* useAttrs()是获取本组件的所有属性对象
*/
const attrs = new Proxy(useAttrs(), {
  //监听本组件属性的读取
  get(target, prop: string, receiver) {
  	//如果读取的属性名称是modal-class 并且有值
    if (prop === "modal-class" && target[prop]) {
      //根据animation值,判断开启或打开是否使用动画,动画class为属性值animationOpenClass和animationCloseClass 
      const preClass = isOpen.value
        ? `my-dialog__component ${
            props.animation
              ? `animate__animated ${props.animationOpenClass || ""}`
              : ""
          }`
        : `my-dialog__component ${
            props.animation
              ? `animate__animated ${props.animationCloseClass || ""}`
              : ""
          }`;
      return `${preClass} ${target[prop]}`;
    }
    //否则,原样返回
    return target[prop];
  },
});
//弹窗状态 true-打开 false-关闭
const isOpen = ref(attrs.modelValue);
/** 打开弹窗方法 */
const open = () => {
  isOpen.value = true;
};
/** 关闭弹窗方法 */
const close = () => {
  isOpen.value = false;
};
//获取本组件slot节点集合
const slots = useSlots();
</script>
<template>
  <el-dialog
    ref="dialogRef"
    :modal-class="
      isOpen
        ? `my-dialog__component ${
            props.animation
              ? `animate__animated ${props.animationOpenClass || ''}`
              : ''
          }`
        : `my-dialog__component ${
            props.animation
              ? `animate__animated ${props.animationCloseClass || ''}`
              : ''
          }`
    "
    append-to-body
    :close-on-click-modal="false"
    @open="open"
    @close="close"
    v-bind="attrs">
    <!--本组件的slot节点继承el-dialog组件-->
    <template v-for="(item, key) in slots" v-slot:[key]>
      <slot :name="key"></slot>
    </template>
  </el-dialog>
</template>
<style lang="less">
.my-dialog__component.animate__animated {
  --animate-duration: 0.4s;//设置animate.css动画时间
  background: rgba(0, 0, 0, 0.1);
  .el-dialog {
    border-radius: 16px;
    .el-dialog__header {
      .el-dialog__headerbtn {
        top: -38px;
        right: -42px;
        outline: none;
        .el-dialog__close {
          color: #fff;
          border: 2px solid;
          padding: 6px;
          border-radius: 50px;
          font-weight: 900;
          font-size: 18px;
        }
      }
    }
  }
}
</style>

通用弹窗组件写完,就可以写具体的弹窗了

4.设置型弹窗组件

<!-- /src/components/my-dialog-set.vue -->
<script setup lang="ts">
import { useSlots, withDefaults } from "vue";
import MyDialog from "@/components/my-dialog.vue";//引入上面编写的通用弹窗组件

interface Props {
  showOkBtn?: boolean;
  showCancelBtn?: boolean;
  okBtnText?: string;
  cancelBtnText?: string;
  loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  showOkBtn: true,//是否显示确定按钮
  showCancelBtn: true,//是否显示取消按钮
  okBtnText: "保存",//确定按钮文案
  cancelBtnText: "取消",//取消按钮文案
  loading: false,//弹窗状态是否在加载中
});
//本组件slot节点集合,默认footer节点为空
const slots = { footer: () => {}, ...useSlots() };
//触发的事件
const emit = defineEmits(["ok", "cancel", "close", "update:modelValue"]);
/** 点击取消按钮的方法 */
const cancel = () => {
  emit("cancel");
  emit("update:modelValue", false);
};
/** 点击确定按钮的方法 */
const ok = () => {
  emit("ok");
};
/** 弹窗关闭处罚的事件 */
const closed = () => {
  emit("update:modelValue", false);
  emit("close");
};
</script>
<template>
  <my-dialog @close="closed">
  	<!--循环节点-->
    <template v-for="(item, key) in slots" v-slot:[key]>
      <!--暴露狂:将循环的节点全部暴露出去-->
      <slot :name="key">
        <!--对footer节点的默认做单独处理-->
        <div :key="key" v-if="key === 'footer'" class="dialog-footer">
          <!--取消按钮 可用状态受loading属性作用-->
          <el-button v-if="showCancelBtn" @click="cancel" :loading="props.loading">{{ props.cancelBtnText }}</el-button>
          <!--确定按钮 可用状态受loading属性作用-->
          <el-button v-if="showOkBtn" type="primary" @click="ok" :loading="props.loading" >{{ okBtnText }}</el-button>
        </div>
      </slot>
    </template>
  </my-dialog>
</template>

5.详情型弹窗组件

原理同上,只不过按钮部分只有取消按钮的配置

<!-- /src/components/my-dialog-detail.vue -->
<script setup lang="ts">
import { useSlots, withDefaults } from "vue";
import MyDialog from "@/components/my-dialog.vue";

interface Props {
  showCancelBtn?: boolean;
  cancelBtnText?: string;
  loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  showCancelBtn: true,//是否显示取消按钮
  cancelBtnText: "关闭",//取消按钮文案
  loading: false,//弹窗状态是否在加载中
});
const slots = { footer: () => {}, ...useSlots() };
const emit = defineEmits(["cancel", "update:modelValue"]);
const cancel = () => {
  emit("cancel");
  emit("update:modelValue", false);
};
const closed = () => {
  emit("update:modelValue", false);
};
</script>
<template>
  <my-dialog close-on-click-modal @closed="closed">
    <template v-for="(item, key) in slots" v-slot:[key]>
      <slot :name="key">
        <div :key="key" v-if="key === 'footer'" class="dialog-footer">
          <el-button v-if="showCancelBtn" @click="cancel" :loading="props.loading" >{{ props.cancelBtnText }}</el-button>
        </div>
      </slot>
    </template>
  </my-dialog>
</template>

弹窗内容组件(用于测试)

本章技能前摇有些大,终于可以输出伤害了

还记得前面top-header.data.ts文件么

在这里插入图片描述
一切准备完毕,可以编写AddressBook组件了。

<!-- /src/pages/top-header/components/address-book.vue -->
<script setup lang="ts">
import { ElMessage } from "element-plus";
/** 定义ok方法 */
const ok = () => {
  //返回Promise对象
  return new Promise((resolve, reject) => {
    //虚拟和后端交互的时间
    setTimeout(() => {
      //将ok字符resolve出去,会有ok字符打的提示
      resolve('ok!');
    }, 3000);
  });
};
defineExpose({ ok });
</script>
<template>
  <span>一个朋友也没有...</span>
</template>

前摇是大些,释放时还是挺干净利落的。

在这里插入图片描述

在这里插入图片描述

更新用户信息页面

接下来,实现链接的路由模式

在这里插入图片描述
1.添加路由

/** /src/router/modules/user-info.ts */
import { type ModulesItemType } from "../modules.type"
export default {
    menuChildren:[
        {  path: 'edit', component: () => import("@/pages/user-info/edit.vue") },
    ]
} as ModulesItemType

2.更新用户信息页面

<!-- /src/pages/user-info/edit.vue -->
<script lang="ts" setup>
import { reactive, ref, watch } from "vue";
//MyUploadImage 上传图片组件
import MyUploadImage from "@/components/my-upload-image.vue";
//MyCropper 裁剪图片组件
import MyCropper, { UploadSuccessType } from "@/components/my-cropper.vue";
import { useUserStore } from "@/store/index";
import http from "@/http";
import { ElMessage } from "element-plus";

interface RuleForm {
  username: string;
  nickname?: string;
  avatar?: string;
  signature?: string;
  sex: "0" | "1";
  birth?: string;
  email?: string;
}
const userStore = useUserStore();
const cropperShow = ref(false);//MyCropper 裁剪图片组件显隐
const ruleForm = ref<RuleForm>({});//更新用户信息接口入参
const fileList = ref([]);//MyUploadImage上传图片组件fileList属性数据
/** el-date-picker组件禁止选择的日期方法 */
const disabledDate = (D: Date) => D.getTime() > new Date().getTime();
/** 点击MyUploadImage上传图片组件的上传图标事件-显示MyCropper裁剪图片组件 */
const myUploadClick = () => {
  cropperShow.value = true;
};
/** MyCropper裁剪图片组件上传成功事件 */
const uploadSuccess = (e: UploadSuccessType) => {
  fileList.value[0] = { name: e.name, url: e.url };
  ruleForm.value.avatar = e.url;
};
/** 点击表单提交 */
const updateUserName = async () => {
  ruleForm.value.avatar = fileList.value[0]?.url || "";
  apiUpdateUserInfo(ruleForm.value);
};
/** 更新用户信息接口 */
const apiUpdateUserInfo = (p = {}) => {
  return http.post("users/updateUserInfo", p) .then((res: any) => {
      //data 更新用户信息成功后返回更新后用户信息
      const { code, data, message } = res;
      if (code === 200) {
        ElMessage({ message: message, type: "success" });
        //将更新后的用户信息存储在Pinia仓储
        userStore.setUserInfo(data);
      } else {
        ElMessage({ message: message, type: "error" });
      }
   }).catch((err: any) => {
      ElMessage({ message: err.message, type: "error" });
  });
};
/** 监听Pinia仓储userInfo变量(存储用户信息) 一旦有变化即更新本页面的数据 */
watch(
  () => userStore.userInfo,
  (newValue: any) => {
    if (newValue) {
      ruleForm.value = JSON.parse(JSON.stringify(newValue));
      if (newValue.avatar) {
        fileList.value[0] = {
          name: newValue.avatar.split("/").pop(),
          url: newValue.avatar,
        };
      }
    }
  },
  { deep: true, immediate: true }
);
</script>
<template>
  <div class="user-four">
    <el-form :model="ruleForm" label-width="120px" class="demo-ruleForm" status-icon>
      <el-form-item label="昵称" prop="nick_name">
        <el-input v-model="ruleForm.nickname" />
      </el-form-item>
      <el-form-item label="头像" prop="avatar">
        <!-- 上传图片组件 -->
        <MyUploadImage
          v-model:fileList="fileList"
          :size="[50, 50]"
          circle
          :plugins="['preview', 'del']"
          pluginsSize="14px"
          stop
          @uploadClick="myUploadClick" />
      </el-form-item>
      <el-form-item label="签名" prop="signature">
        <el-input v-model="ruleForm.signature" />
      </el-form-item>
      <el-form-item label="性别" prop="sex">
        <el-radio-group v-model="ruleForm.sex">
          <el-radio label="1"></el-radio>
          <el-radio label="0"></el-radio>
        </el-radio-group>
      </el-form-item>
      <el-form-item label="出生日期" prop="birth">
        <el-date-picker
          v-model="ruleForm.birth"
          type="date"
          value-format="YYYY-MM-DD"
          :disabled-date="disabledDate"
          placeholder="请选择出生日期" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="ruleForm.email" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="updateUserName">提交</el-button>
      </el-form-item>
    </el-form>
    <!-- 裁剪图片组件 -->
    <my-cropper
      v-model="cropperShow"
      :fixedNumber="[200, 200]"
      :outputOrigin="false"
      @uploadSuccess="uploadSuccess"></my-cropper>
  </div>
</template>
<style>
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  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: 50px;
  height: 50px;
  text-align: center;
}
</style>

其视图为
在这里插入图片描述
不过现在还显示不出来,因为有些工作还没完成,如果都读到这里了,请别放弃

MyCropper自定义裁剪图片组件

1.安装vue-cropper组件

npm install vue-cropper

2.编写MyCropper组件

<!-- /src/components/my-cropper.vue -->
<!--setup写法禁止属性透传:单独添加一个script标签,暴露一个配置对象-->
<script lang="ts">
export default {
  inheritAttrs: false, // 禁用
};
</script>
<script setup lang="ts">
import { ref, withDefaults, watch, nextTick } from "vue";
import { RefreshRight, RefreshLeft, ZoomIn, ZoomOut, Back, Right, List, CircleClose, ArrowLeftBold, InfoFilled } from "@element-plus/icons-vue";
import MyDialogSet from "@/components/my-dialog-set.vue";
import "vue-cropper/dist/index.css";//引入vue-cropper组件样式
import { VueCropper } from "vue-cropper";
import type { UploadFile, UploadFiles } from "element-plus";
import { ElMessage } from "element-plus";
import http from "@/http";
import { useUserStore } from "@/store/index";

export interface UploadSuccessType {
  url: string;
  name: string;
  uid: number;
  status: "success";
  key: any;
}
interface Props {
  key?: any; //用于区别多个使用该组件的标识
  modelValue: boolean;//组件的显隐,双向数据绑定
  dialogWidth?: string; //弹窗宽度
  initAfterClose?: boolean; //关闭后是否初始化
  btnGroup?: string[]; //输入区域(左侧)下方显示的按钮控件
  img?: string; //裁剪图片的地址(url 地址, base64, blob)
  fixedNumber?: number[] | false; //固定比例截图输出的分辨率,若为false,则为自由比例截图(outputOrigin若为false,则输出的图片分辨率为fixedNumber*enlarge)
  autoCrop?: boolean; //是否默认生成截图框
  infoTrue?: boolean; //true 为展示真实输出图片宽高 false 展示看到的截图框宽高
  original?: boolean; //上传图片按照原始比例渲染
  outputType?: string; //裁剪生成图片的格式:jpeg, png, webp
  outputSize?: number; //裁剪生成图片的质量 0.1 ~ 1
  fillColor?: string; //导出时背景颜色填充
  mode?: string; //图片默认渲染方式:contain , cover, 100px, 100% auto
  centerBox?: boolean; //截图框是否被限制在图片里面
  high?: boolean; //是否按照设备的dpr 输出等比例图片
  enlarge?: number; //图片根据截图框输出比例倍数(0 ~ max(建议不要太大不然会卡死的呢))
  limitMinSize?: number | number[] | string; //裁剪框限制最小区域(Number, Array, String)
  canMoveBox?: boolean; //截图框能否拖动
  canMove?: boolean; //上传图片是否可以移动
  fixedBox?: boolean; //固定截图框大小
  full?: boolean; //是否输出原图比例的截图
  canScale?: boolean; //图片是否允许滚轮缩放
  info?: boolean; //裁剪框的大小信息
  maxImgSize?: number; //限制图片最大宽度和高度(0 ~ max)
  autoCropWidth?: string | number; //默认生成截图框宽度(0 ~ max,默认容器的 80%)
  autoCropHeight?: string | number; //默认生成截图框高度(0 ~ max,默认容器的 80%)
  outputOrigin?: boolean; //以原图为基准输出(与enlarge属性共同作用输出、权重大于fixedNumber属性)
  showInputTip?: boolean; //是否显示输入区域(左侧)的提示语
  inputTip?: string; //自定义输入区域(左侧)的提示语
  showOutputTip?: boolean; //是否显示输出区域(右侧)的提示语
}
const props = withDefaults(defineProps<Props>(), {
  key: "",
  modelValue: false,
  dialogWidth: "55vw",
  initAfterClose: true,
  btnGroup: ["rotateRight", "rotateLeft", "zoomIn", "zoomOut", "goNext", "goPrev", "showList"],
  img: "",
  fixedNumber: false,
  autoCrop: true,
  infoTrue: false,
  original: false,
  outputType: "jpg",
  outputSize: 1,
  fillColor: "",
  mode: "contain",
  centerBox: false,
  high: true,
  enlarge: 1,
  limitMinSize: 10,
  canMoveBox: true,
  canMove: true,
  fixedBox: false,
  full: false,
  canScale: true,
  info: true,
  maxImgSize: 2000,
  outputOrigin: true,
  showInputTip: true,
  inputTip: "",
  showOutputTip: true,
});
const emits = defineEmits(["update:modelValue", "uploadSuccess"]);
const userStore = useUserStore();
const cropperRef = ref();//右侧输出区域(vue-cropper组件)的Ref
const img = ref("");//当前待处理的图片地址
const previewImg = ref("");//右侧输出区域的图片地址(实时预览)
const base64List = ref([]);//【选择图片】按钮el-upload组件的数据(没什么用)
const multipleListShow = ref(false);//待处理图片列表显隐
const waitUploadFiles = ref<uploadFiles>([]); //待处理图片列表
const currentUploadFileIndex = ref(0); //当前待处理图片的索引
const cropperSize = { w: 0, h: 0 };//左侧输入区域的大小,用于计算输出图片比例
const uploadImageSize = ref(0); //即将上传的图片字节大小

/** 监听本组件img属性的变化,使组件内部img变量与其保持一致,用于重新裁剪 */
watch(() => props.img, (newValue: string) => img.value = newValue, { immediate: true });
/** 监听本组件的显隐  */
watch(() => props.modelValue, async (newValue: string) => {
    if (newValue) {
      //如果本组件显示,则计算左侧输入区域的大小,存储到cropperSize变量中,并且实时监听
      await nextTick();
      cropperSize.w = cropperRef.value?.$el?.offsetWidth || 0;
      cropperSize.h = cropperRef.value.$el.offsetHeight || 0;
      window.xyResizeEventListener = () => {
        cropperSize.w = cropperRef.value?.$el?.offsetWidth || 0;
        cropperSize.h = cropperRef.value.$el.offsetHeight || 0;
      };
      window.addEventListener("resize", window.xyResizeEventListener);
    } else {
      //如果本组件隐藏,则重置cropperSize变量,并且移除监听
      cropperSize.w = 0;
      cropperSize.h = 0;
      if (window.xyResizeEventListener) {
        window.removeEventListener("resize", window.xyResizeEventListener);
        window.xyResizeEventListener = null;
      }
    }
  },
  { immediate: true }
);
/** 关闭本组件的方法 */
const close = () => {
  emits("update:modelValue", false);
  if (props.initAfterClose) init();
};
/** 通过MyDialogSet组件触发的关闭
*** 不用双向数据绑定是因为本组件的modelValue属性是prop,不能在组件内部改变其值
*** 所以通过事件触发props.modelValue的改变
*/
const g = (e: boolean) => {
  emits("update:modelValue", e);
};
/** vue-cropper的实时预览事件触发该方法 */
const realTime = () => {
  //获取预览图片的base64数据
  cropperRef.value.getCropData((data) => {
    previewImg.value = data;
  });
  //获取预览图片的blob数据,得到图片存储大小
  cropperRef.value.getCropBlob((data) => {
    uploadImageSize.value = data.size;
  });
};
/** File对象转化为Bloburl */
const file2ObjectUrl = (file: File) => {
  let url = null;
  if (window.createObjectURL != undefined) {
    url = window.createObjectURL(file);
  } else if (window.URL != undefined) {
    url = window.URL.createObjectURL(file);
  } else if (window.webkitURL != undefined) {
    url = window.webkitURL.createObjectURL(file);
  }
  return url;
};
/** Blob对象转化为File对象 */
const blob2File = (blob: Blob, filename: string, type: string) => {
  return new File([blob], filename, { type });
};
/** File对象转化为Base64 */
const file2Base64 = (blob: File | Blob) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (evt: Event) => {
      const base64 = evt.target.result;
      resolve(base64);
    };
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
};
/** 从待上传的文件列表中整理当前待处理的图片,并返回该图片的File对象 */
const makeCurrentUploadFile = () => {
  let currentUploadFile;//存储当前待处理图片的变量
  if (
    currentUploadFileIndex.value >= 0 &&
    (currentUploadFile = waitUploadFiles.value[currentUploadFileIndex.value])
  ) {
    //若通过currentUploadFileIndex索引能在待上传的文件列表中拿到图片,则在左侧输入区域显示该图片
    const objectUrl = file2ObjectUrl(currentUploadFile.raw);
    img.value = objectUrl;
    return currentUploadFile;
  } else {
    //否则,左侧输入区域控件什么也不显示(用于图片都已处理完成)
    img.value = "";
    previewImg.value = "";
    return null;
  }
};
/**获取图片的宽高 */
const getImageSize = (file: File) => {
  return file2Base64(file).then((res: string) => {
    return new Promise((resolve, reject) => {
      const image = new Image();
      image.onload = () => {
        resolve({ width: image.width, height: image.height });
      };
      image.onerror = reject;
      image.src = res;
    });
  });
};
/** 点击【选择图片】选择完图片后触发的方法 */
const fileChange = (uploadFile: UploadFile, uploadFiles: UploadFiles) => {
  //保证currentUploadFileIndex索引最小值为0
  if (currentUploadFileIndex.value < 0) currentUploadFileIndex.value = 0;
  //计算图片真实尺寸相对vue-croppper画布尺寸的放大系数,存入入参uploadFile对象中,以备裁剪完输出图片时使用
  getImageSize(uploadFile.raw).then(
    (res: { width: number; height: number }) => {
      //获取该图片的真实宽高
      const { width, height } = res;
      uploadFile.width = width;
      uploadFile.height = height;
      //计算该图片真实宽度相对vue-croppper画布宽度的放大系数,最小值为1
      const enlargeWidth = width > cropperSize.w ? width / cropperSize.w : 1;
      //按照该放大系数推算该图片在画布中应该的高度
      const e_h = height / enlargeWidth;
      if (e_h <= cropperSize.h) {
         //若该高度未溢出画布,则采用以宽度计算的放大系数,最小值为1
        uploadFile.enlarge = enlargeWidth || 1;
      } else {
         //若该高度溢出画布,则采用以高度计算的放大系数,最小值为1
        uploadFile.enlarge = enlargeWidth * (e_h / cropperSize.h) || 1;
      }
    }
  );
  //将该图片信息存入到待上传的文件列表,以备上传使用
  waitUploadFiles.value.unshift(uploadFile);
  //从待上传的文件列表中整理当前待处理图片,显示在vue-cropper组件中
  makeCurrentUploadFile();
};
/** 从待上传的文件列表中手动选择要处理的图片 */
const chooseWaitUploadFile = (index: number) => {
  currentUploadFileIndex.value = index;
  makeCurrentUploadFile();
};
/** 左侧输入区域控件方法:向右旋转图片 */
const rotateRight = () => {
  cropperRef.value.rotateRight();
};
/** 左侧输入区域控件方法:向左旋转图片 */
const rotateLeft = () => {
  cropperRef.value.rotateLeft();
};
/** 左侧输入区域控件方法:放大图片 */
const zoomIn = () => {
  cropperRef.value.changeScale(1);
};
/** 左侧输入区域控件方法:缩小图片 */
const zoomOut = () => {
  cropperRef.value.changeScale(-1);
};
/** 左侧输入区域控件方法:选择下一个图片(衔尾式进行) */
const goNext = () => {
  const len = waitUploadFiles.value.length || 0;
  if (len > 0) {
    const nextIndex = (currentUploadFileIndex.value + 1) % len;
    chooseWaitUploadFile(nextIndex);
  }
};
/** 左侧输入区域控件方法:选择上一个图片(衔尾式进行) */
const goPrev = () => {
  const len = waitUploadFiles.value.length || 0;
  if (len > 0) {
    const index = (currentUploadFileIndex.value - 1) % len;
    const prevIndex = index < 0 ? len + index : index;
    chooseWaitUploadFile(prevIndex);
  }
};
/** 从待上传的文件列表中删除图片的方法 */
const delWaitUploadFile = (index: number) => {
  if (currentUploadFileIndex.value > index) {
    //若待删除图片在当前待处理图片顺序的上方,则当前待处理图片不变
    waitUploadFiles.value.splice(index, 1);
    chooseWaitUploadFile(currentUploadFileIndex.value - 1);
  } else if (currentUploadFileIndex.value < index) {
    //若待删除图片在当前待处理图片顺序的下方,则当前待处理图片不变
    waitUploadFiles.value.splice(index, 1);
  } else {
    //若待删除图片为当前待处理图片,则当前待处理图片变为下一个,若待删除图片已处于列表底部,则当前待处理图片变为上一个
    const maxIndex = waitUploadFiles.value.length - 1;
    waitUploadFiles.value.splice(index, 1);
    if (index === maxIndex) {
      chooseWaitUploadFile(currentUploadFileIndex.value - 1);
    } else {
      chooseWaitUploadFile(currentUploadFileIndex.value);
    }
  }
};
/** 上传图片的接口 */
const upload = () => {
  cropperRef.value.getCropBlob((data: Blob) => {
    //将当前已裁剪图片的Blob对象转化为File对象,且图片名称和MIMEType不变
    const currentUploadFile = waitUploadFiles.value[currentUploadFileIndex.value];
    const file = blob2File(data, currentUploadFile.raw.name, currentUploadFile.raw.type);
    //将该File对象和用户名(已存储在Pinia仓储中了)整理成FormData对象作为上传接口的入参
    const formData = new FormData();
    formData.append("file", file);
    formData.append("username", userStore.userName);
    return http.post("files/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }).then((res: any) => {
        const { code, message, data } = res;
        if (code === 0) {
          ElMessage({ message: message, type: "success" });
          //上传成功则从待处理列表中删除该图片
          delWaitUploadFile(currentUploadFileIndex.value);
          if (data) {
            emits("uploadSuccess", {
              url: data.path,
              name: data.path.split(/\//gim).pop(),
              uid: new Date().getTime(),
              status: "success",
              key: props.key,
            } as UploadSuccessType);
          }
          //若待处理列表为空,则关掉该组件
          (waitUploadFiles.value.length == 0) && close();
        } else {
          ElMessage({ message: message, type: "error" });
        }
      }).catch((err: any) => {
        ElMessage({ message: err.message, type: "error" });
      });
  });
  return;
};
/** 获取裁剪完输出的图片缩放系数 */
const getEnlarge = (props: Props, files: UploadFiles, index: number) => {
  let enlarge = props.enlarge;
  if (props.outputOrigin) {
    //以原图为基准计算缩放系数
    enlarge = (files[index]?.enlarge || 1) * props.enlarge;
  } else {
    if (!!props.fixedNumber) {
      //以fixedNumber值为基准缩放原图
      const fixedNumberEnlargeW = cropperRef.value?.cropW
        ? props.fixedNumber[0] / cropperRef.value?.cropW
        : 1;
      const fixedNumberEnlargeH = cropperRef.value?.cropH
        ? props.fixedNumber[1] / cropperRef.value?.cropH
        : 1;
      enlarge =
        Math.min(fixedNumberEnlargeW, fixedNumberEnlargeH) * props.enlarge;
    }
  }
  return enlarge;
};
/** 获取左侧输入区域的提示文字 */
const getInputTip = (props: Props) => {
  if (!!props.inputTip) {
    return props.inputTip;
  } else {
    return !!props.fixedNumber
      ? `推荐上传图片分辨率 ${props.fixedNumber[0]} * ${props.fixedNumber[1]},或该比例`
      : ``;
  }
};
/** 获取右侧输出区域的提示文字 */
const getOutputTip = (props: Props, files: UploadFiles, index: number) => {
  const enlarge = getEnlarge(props, files, index);
  const w = cropperRef.value?.cropW;
  const h = cropperRef.value?.cropH;
  if (w && h) {
    return `输出图片分辨率${Math.round(w * enlarge)} * ${Math.round(
      h * enlarge
    )},占用存储${filterSize(uploadImageSize.value)}`;
  }
  return "";
};
/** 将size字节数换算成相应的单位 */
const filterSize = (size: number) => {
  if (!size) return "";
  if (size < pow1024(1)) return size + " B";
  if (size < pow1024(2)) return (size / pow1024(1)).toFixed(2) + " KB";
  if (size < pow1024(3)) return (size / pow1024(2)).toFixed(2) + " MB";
  if (size < pow1024(4)) return (size / pow1024(3)).toFixed(2) + " GB";
  return (size / pow1024(4)).toFixed(2) + " TB";
};
/** 求次幂 */
const pow1024 = (num: number) => Math.pow(1024, num);
/** 初始化 */
const init = () => {
  img.value = "";
  previewImg.value = "";
  base64List.value = [];
  multipleListShow.value = false;
  waitUploadFiles.value = [];
  currentUploadFileIndex.value = 0;
};
</script>
<template>
  <my-dialog-set
    @close="close"
    @cancel="close"
    :model-value="props.modelValue"
    @update:modelValue="g"
    :style="{ width: props.dialogWidth }"
    modal-class="my-cropper__component">
    <div class="cropper-wrap" style="width: 48%; height: 45vh">
      <!-- 左侧输入区域 -->
      <VueCropper
        ref="cropperRef"
        class="cropper"
        style="width: 100%; height: 100%"
        :fixedNumber="!!props.fixedNumber ? props.fixedNumber : [1, 1]"
        :fixed="!!props.fixedNumber"
        :autoCrop="props.autoCrop"
        :infoTrue="props.infoTrue"
        :original="props.original"
        :outputType="props.outputType"
        :outputSize="props.outputSize"
        :fillColor="props.fillColor"
        :mode="props.mode"
        :centerBox="props.centerBox"
        :high="props.high"
        :enlarge="getEnlarge(props, waitUploadFiles, currentUploadFileIndex)"
        :limitMinSize="props.limitMinSize"
        :canMoveBox="props.canMoveBox"
        :canMove="props.canMove"
        :fixedBox="props.fixedBox"
        :full="props.full"
        :canScale="props.canScale"
        :info="props.info"
        :maxImgSize="props.maxImgSize"
        :autoCropWidth="props.autoCropWidth"
        :autoCropHeight="props.autoCropHeight"
        @realTime="realTime"
        :img="img" />
        <!-- 左侧输入区域的提示 -->
      <div class="input-tip-wrap" v-if="props.showInputTip">
        <el-icon v-if="getInputTip(props)"><InfoFilled /></el-icon
        >{{ getInputTip(props) }}
      </div>
      <!-- 左侧输入区域的控件组 -->
      <div class="btn-group">
        <el-button v-if="props.btnGroup.includes('rotateRight')" type="primary" :icon="RefreshRight" @click="rotateRight" />
        <el-button v-if="props.btnGroup.includes('rotateLeft')" type="primary" :icon="RefreshLeft" @click="rotateLeft" />
        <el-button v-if="props.btnGroup.includes('zoomIn')" type="primary" :icon="ZoomIn" @click="zoomIn" />
        <el-button v-if="props.btnGroup.includes('zoomOut')" type="primary" :icon="ZoomOut" @click="zoomOut" />
        <el-button v-if="props.btnGroup.includes('goPrev')" type="primary" :icon="Back" @click="goPrev" />
        <el-button v-if="props.btnGroup.includes('goNext')" type="primary" :icon="Right" @click="goNext" />
        <el-badge v-if="props.btnGroup.includes('showList')" :value="waitUploadFiles.length" :hidden="waitUploadFiles.length == 0" :is-dot="waitUploadFiles.length == 1">
          <el-button type="primary" :icon="List" @click="multipleListShow = !multipleListShow" />
        </el-badge>
      </div>
    </div>
    <div class="preview-wrap" style="width: 48%">
      <div class="preview-view" style="width: 100%; height: 45vh">
        <!-- 右侧输出区域 -->
        <img class="preview-img" v-if="previewImg" :src="previewImg" />
        <!-- 右侧待处理图片列表区域 -->
        <div class="multiple-list" :class="{ show: multipleListShow }">
          <div class="title">
            <el-icon class="back-icon" @click="multipleListShow = false">
                <ArrowLeftBold />
            </el-icon>
            待裁剪图片列表
          </div>
          <div v-for="(i, $index) in waitUploadFiles" @click="chooseWaitUploadFile($index)" :key="i.raw.uid" class="multiple-item" :class="{ actived: currentUploadFileIndex === $index }">
            <div class="file-name" :title="i.raw.name">{{ i.raw.name }}</div>
            <div class="img-view">
              <img :src="file2ObjectUrl(i.raw)" />
            </div>
            <div class="operation-group">
              <el-icon @click.stop="delWaitUploadFile($index)"><CircleClose /></el-icon>
            </div>
          </div>
        </div>
      </div>
      <!-- 右侧输出提示区域 -->
      <div class="output-tip-wrap" v-if="props.showOutputTip">
        <el-icon v-if="getOutputTip(props, waitUploadFiles, currentUploadFileIndex)">
        	<InfoFilled />
        </el-icon>
        {{ getOutputTip(props, waitUploadFiles, currentUploadFileIndex) }}
      </div>
    </div>
    <!-- 底部按钮组 -->
    <template #footer>
      <el-button @click="close">取消</el-button>
      <el-upload
        class="uplaod-btn-wrap"
        :file-list="base64List"
        :show-file-list="false"
        :auto-upload="false"
        :multiple="true"
        :on-change="fileChange">
        <el-button type="warning">选择图片</el-button>
      </el-upload>
      <el-button type="primary" @click="upload" :disabled="!previewImg">上传</el-button>
    </template>
  </my-dialog-set>
</template>
<style lang="less">
.my-cropper__component {
  --animate-duration: 0.4s;
  .cropper-wrap {
    display: inline-block;
    .cropper {
    }
    .btn-group {
      padding-top: 10px;
      & > * {
        margin-left: 12px;
      }
    }
    .input-tip-wrap {
      font-size: 12px;
      color: #999;
    }
  }
  .preview-wrap {
    position: relative;
    display: inline-block;
    align-items: center;
    justify-content: center;
    vertical-align: top;
    margin-left: 2%;
    .preview-view {
      position: relative;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      background-image: url(../assets/Images/cropper_preview_bg.png);
      background-color: #ccc;
      background-repeat: repeat;
      overflow: hidden;
      .preview-img {
        max-width: 100%;
        height: auto;
      }
      .multiple-list {
        position: absolute;
        top: 0;
        left: 100%;
        width: 100%;
        height: 100%;
        overflow: auto;
        background: #fff;
        transition: all 0.5s;
        padding: 20px 0;
        box-sizing: border-box;
        &.show {
          left: 0%;
        }
        .title,
        .multiple-item {
          padding-inline: 15px;
        }
        .title {
          font-size: 16px;
          font-weight: bold;
          margin-bottom: 10px;
          vertical-align: middle;
          .back-icon {
            vertical-align: middle;
            cursor: pointer;
          }
        }
        .multiple-item {
          display: flex;
          align-items: center;
          font-size: 14px;
          height: 3em;
          line-height: 3em;
          cursor: pointer;

          &.actived {
            background: rgba(149, 239, 219, 0.2);
          }
          &:hover {
            background: rgba(149, 239, 219, 0.2);
            .operation-group {
              display: block;
            }
          }
          .file-name {
            flex-basis: 50%;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
          }
          .img-view {
            flex-basis: 35%;
            box-sizing: border-box;
            overflow: hidden;
            height: 80%;
            img {
              // width: 100%;
              height:100%;
            }
          }
          .operation-group {
            flex-basis: 15%;
            display: none;
            text-align: center;
          }
        }
      }
    }
    .output-tip-wrap {
      font-size: 12px;
      color: #999;
    }
  }
  .uplaod-btn-wrap {
    display: inline-block;
    margin-inline: 12px;
  }
}
</style>

请添加图片描述

请添加图片描述

MyUploadImage自定义上传图片组件

<!-- /src/components/my-upload-image.vue -->
<script lang="tsx" setup>
import { ref, withDefaults } from "vue";
import { ElMessage } from "element-plus";
import type { UploadProps, UploadFile, UploadRequestOptions } from "element-plus";
import { Delete, Download, Plus, ZoomIn } from "@element-plus/icons-vue";
import http, { httpHost } from "@/http";
import MyPreviewImage from "@/components/my-preview-image.vue";//预览组件
import { MyDeleteMessageBox } from "@/components/my-delete-message-box.vue";//删除提示组件
import { useUserStore } from "@/store/index";
interface fileListType {
  url: string;
  name: string;
  uid?: number;
  status?: string;
}
type pluginNameType = "preview" | "download" | "del";
interface Props {
  fileList?: fileListType[];
  size?: string | number[];
  uploadIconWidth?: string | number;
  plugins?: pluginNameType[];
  pluginsSize?: string | number;
  circle?: boolean;
  stop?: boolean;
  immediate?: boolean;
  maxNum?: number;
  removeApi?: (path?:string) => Promise<any> | null;
}
interface uploadCacheType {
  bloburl: sting;
  file: File;
}
//该组件接受的属性
const props = withDefaults(defineProps<Props>(), {
  fileList: [],//要显示的图片数据,双向数据绑定
  size: ["auto", "auto"],//图片大小,分别表示宽和高
  plugins: ["preview", "download", "del"],//要显示的图片控件,分别是预览、下载和删除
  pluginsSize: "18px",//要显示的图片控件的大小
  circle: false,//图片是否圆形显示,默认是圆角
  stop: false,//点击上传图标是否阻止冒泡-true:启用自定义上传行为时用 false-启用默认的上传文件行为
  immediate: true,//当stop为false时,选择完图片后是否立即上传服务器
  maxNum: 1,//最多上传图片数量,maxNum<0则不做限制
  removeApi: null,//自定义删除图片api方法,该方法参数path,表示当前图片路径,返回值为Promise对象,其resolve或reject可以是任意值。若该属性是null则用组件默认的方法
});
//该组件触发的事件
const emits = defineEmits([
  "update:fileList",//fileList属性的双向数据绑定
  "uploadClick",//点击上传图标时触发
  "prepareUpload",//当stop为false且immediate属性为false时,选择图片后触发
  "prepareDelete",//当stop为false且immediate属性为false时,删除图片后触发
  "uploadSuccess",//当stop为false且immediate属性为true时,上传图片成功时触发
  "deleteSuccess",//删除图片成功时触发
  "uploadProgress",//当stop为false时的上传进度事件
]);
const userStore = useUserStore();
const currentImageSrc = ref("");//要预览的图片链接
const previewShow = ref(false);//预览组件的显隐
const myHttpHost = httpHost.replace(/\/$/, "");//服务器地址
//当stop为false且immediate属性为true时,用于存储要上传图片的信息集合
const uploadCacheList = ref<uploadCacheType[]>([]);
//当stop为false且immediate属性为true时,用于存储要删除图片的信息集合
const removeCacheList = ref<String[]>([]);

/** 点击预览图片控件的方法 */
const handlePictureCardPreview = (file: UploadFile) => {
  previewShow.value = true;
  //若是服务器图片则添加服务器地址前缀,若是bloburl则不作处理
  currentImageSrc.value = isBlobUrl(file.url) ? file.url : myHttpHost + file.url;
};
/** 下载图片的方法 */
const downloadImage = (blobUrl: string, filename: string) => {
  const aElement = document.createElement("a");
  aElement.href = blobUrl;
  aElement.download = filename;
  aElement.click();
};
/** 点击下载图片控件的方法 */
const handleDownload = (file: UploadFile) => {
  if (isBlobUrl(file.url)) {
  	//若是bloburl则直接调用downloadImage方法
    downloadImage(file.url, file.name);
  } else {
  	//若是服务器地址则通过http请求将其转化为blob,在将该blob转化为bloburl。调用downloadImage方法
    http.get(file.url, {}, { responseType: "blob" }).then((res: any) => {
        const blobUrl = window.URL.createObjectURL(res);
        downloadImage(blobUrl, file.name);
    }).catch((err: any) => {
        ElMessage({ message: err.message, type: "error" });
    });
  }
};
/** 点击删除图片控件的方法 */
const handleRemove = (file: UploadFile) => {
  const path = file.response ? file.response.url : file.url;
  //若immediate=true,则出现删除前提示,确定后则从服务器删除图片
  if (props.immediate) {
    if(props.removeApi){
      //若removeApi属性有值执行
      await props.removeApi(path)
      emits("update:fileList", props.fileList.filter((item: fileListType) => item.url !== path));
      return;
    }
    //若removeApi属性无值执行默认的删除方法
    MyDeleteMessageBox("hy").then(async (action: string) => {
      await apiRemovePicture(path);
      emits("update:fileList",props.fileList.filter((item: fileListType) => item.url !== path));
      emits("deleteSuccess", file);
    });
  } else {
    //若immediate=false,且该图片是bloburl,则从本地删除图片即可
    if (isBlobUrl(file.url)) {
      uploadCacheList.value = uploadCacheList.value.filter(
        (item: uploadCacheType) => item.bloburl !== file.url
      );
      //释放bloburl内存
      URL.revokeObjectURL(file.url);
    } else {
      //若immediate=false,且该图片是服务器地址,则将该图片存储到removeCacheList变量中,以备后续删除时使用
      removeCacheList.value.push(file.url);
    }
    emits("prepareDelete", path);
    emits("update:fileList",props.fileList.filter((item: fileListType) => item.url !== path));
  }
};
/** 当stop=false时,上传前拦截的方法 */
const beforeAvatarUpload: UploadProps["beforeUpload"] = (rawFile) => {
  if (!/^image\//.test(rawFile.type)) {
    //非图片文件拦截
    ElMessage.error("请上传图片!");
    return false;
  } else if (rawFile.size / 1024 / 1024 > 2) {
  	//文件存储大小拦截
    ElMessage.error("Avatar picture size can not exceed 2MB!");
    return false;
  }
  return true;
};
/** 当stop=false时,上传成功的方法 */
const uploadSuccess: UploadProps["onSuccess"] = (response: fileListType,uploadFile: UploadFile,uploadFiles: UploadFiles) => {
  emits("uploadSuccess", { response, uploadFile, uploadFiles });
  emits("update:fileList", props.fileList.concat([response]));
};
/** 自定义的上传图片方法,该方法仅在stop=false时起作用 */
const upload = async (options: UploadRequestOptions) => {
  //若immediate=true,则调用后端的上传接口
  if (props.immediate) {
    return apiUploadPicture([options.file]);
  } else {
    //若immediate=false,将要上传的图片信息存储到uploadCacheList变量中
    emits("prepareUpload", options);
    const r = readFilesAsBlobUrl(options.file);
    uploadCacheList.value.push({ bloburl: r.bloburl, file: options.file });
    return Promise.resolve({
      url: r.bloburl,
      name: r.filename,
      uid: new Date().getTime(),
      status: "success",
    });
  }
};
/** 获取图片的尺寸 */
const getImageSize = (size: string | number[] = []) => {
  const w = typeof size[0] === "number" ? `${size[0]}px` : size[0];
  const h = typeof size[1] === "number" ? `${size[1]}px` : size[1];
  return { width: w || "auto", height: h || "auto" };
};
/** 获取上传图标的尺寸 */
const getUploadIconWidth = (width: string | number,imageSize: string | number[] = []) => {
  let w: string;
  if (width == 0 || width) {
    w = typeof width === "number" ? `${width}px` : width;
  } else {
    const imgSize = getImageSize(imageSize);
    w = imgSize.width;
  }
  return { width: w || "auto" };
};
/** 获取图片控件的尺寸 */
const getPluginsSize = (pluginsSize: string | number) => {
  const s = typeof pluginsSize === "number" ? `${pluginsSize}px` : pluginsSize;
  return { fontSize: s || "inherit" };
};
/** 获取预览图片地址 */
const getImageUrl = (file: UploadFile) => {
  const url = file.response ? file.response.url : file.url;
  if (!isBlobUrl(url))  return myHttpHost + url;
  return url;
};
/** 判断url是否是blobuurl */
function isBlobUrl(str: string) {
  if (str === "" || str.trim() === "") return false; 
  try {
    return /^blob:/gim.test(str);
  } catch (err) {
    return false;
  }
}
/** 判断是否有图片控件 */
const hasPlugins = (plugins: pluginNameType[]) => {
  return plugins && !!plugins.length;
};
/** 判断是否有某个图片控件 */
const isShowPlugins = (plugins: pluginNameType[], name?: pluginNameType) => plugins && plugins.includes(name);
/** 点击上传图片时触发的方法 */
const clickUploadIcon = (e: Event) => {
  emits("uploadClick");
  if (props.stop) e.stopPropagation();
};
/** 将File对象转化为bloburl */
const readFilesAsBlobUrl = (file: File) => {
  return {
    bloburl: URL.createObjectURL(file),
    filename: file.name,
  };
};
/** 后端接口:删除图片 */
const apiRemovePicture = (path: string) => {
  if(props.removeApi){
    //若removeApi属性有值,则执行
    return props.removeApi(path)
  }
  //若removeApi属性无值,则执行默认的删除方法
  return http.post("files/remove", { path: path }).then((data: any) => data).catch((err: any) => {
      ElMessage({message: err.message,type: "error"});
  });
};
/** 后端接口:上传图片 */
const apiUploadPicture = (fileList: File[]) => {
  const formData = new FormData();
  for (let i in fileList) {
    formData.append("file", fileList[i]);
  }
  formData.append("username", userStore.userName);
  return http.post("files/upload", formData, {
      headers: { "Content-Type": "multipart/form-data" },
      // 上传进度事件
      onUploadProgress: function (progressEvent) {
        emits("uploadProgress", { file: fileList, evt: progressEvent });
      },
    }).then((res: any) => {
      const { code, message, data } = res;
      if (code === 0) {
        if (data) {
            //将上传后的服务器图片返回
	        return Promise.resolve({
	          url: data.path,
	          name: data.path.split(/\//gim).pop(),
	          uid: new Date().getTime(),
	          status: "success",
	        });
        }
      } else {
        ElMessage({ message: message,type: "error" });
        return Promise.reject(message);
      }
    }).catch((err: any) => {
      ElMessage({ message: err.message,type: "error" });
      return Promise.reject(err.message);
    });
};
/** 处理多图片上传(也是逐个调用后台接口) */
const submitUploadFiles = (fileList: File[]) => {
  return Promise.all(fileList.map((f: File) => apiUploadPicture([f]))).then((res: any) => {
      return Promise.resolve(res);
  }).catch((e: any) => {
      return Promise.reject(e);
  });
};
/** 本组件暴露的api:用于immediate=false时,外部主动上传图片 */
const execute = () => {
  return new Promise(async (resolve, reject) => {
    let uploadList = [];//用于本次上传文件信息的集合
    if (uploadCacheList.value.length) {
      //处理缓存的上传图片,将其执行上传任务,并将上传文件信息返回给uploadList变量
      uploadList = await submitUploadFiles(
        uploadCacheList.value.map((item: uploadCacheType) => item.file)
      );
      let t: uploadCacheType;
      //清理已本次上传文件的bloburl对象
      while ((t = uploadCacheList.value.pop())) {
        URL.revokeObjectURL(t.bloburl);
      }
    }
    //【总图片信息】:【原图片】+【本次上传图片】
    const r = props.fileList.filter((item: fileListType) => !isBlobUrl(item.url)).concat(uploadList);
    //处理缓存的删除文件,将其执行上传任务
    if (removeCacheList.value.length) {
      await apiRemovePicture(removeCacheList.value.join(","));
      removeCacheList.value = [];
    }
    //将【总图片信息】resolve给外部
    resolve(r);
  });
};
defineExpose({ execute });
</script>
<template>
  <div class="my-upload-image__component">
    <el-upload
      class="avatar-uploader"
      :class="{ circle: props.circle}"
      accept="image/*"
      :file-list="props.fileList"
      :on-success="uploadSuccess"
      multiple
      :http-request="upload"
      :auto-upload="true"
      list-type="picture-card"
      :show-file-list="true"
      :before-upload="beforeAvatarUpload"
      :disabled="maxNum <= fileList.length && maxNum > 0">
      <!-- 覆盖在上传图标上的透明蒙版,以控制el-upload组件的上传行为 -->
      <div @click="clickUploadIcon" style="position: absolute; inset: -1px; z-index: 1"></div>
      <!-- 上传图标 -->
      <div class="avatar-uploader-icon__wrap" :style="{ ...getUploadIconWidth(props.uploadIconWidth, props.size) }">
        <el-icon class="avatar-uploader-icon"><Plus /></el-icon>
      </div>
      <template #file="{ file }">
        <!-- 图片显示区域 -->
        <div class="preview-list__wrap" :style="{...getImageSize(props.size)}">
        	<!-- 图片控件 -->
          <div v-if="hasPlugins(props.plugins)" class="preview-list__btn-group" :style="getPluginsSize(props.pluginsSize)">
            <!-- 预览控件 -->
            <span v-if="isShowPlugins(props.plugins, 'preview')" class="preview-list__btn-item">
              <el-icon @click="handlePictureCardPreview(file)"><ZoomIn /></el-icon>
            </span>
            <!-- 下载控件 -->
            <span v-if="isShowPlugins(props.plugins, 'download')" class="preview-list__btn-item" >
              <el-icon @click="handleDownload(file)"><Download /></el-icon>
            </span>
            <!-- 删除控件 -->
            <span v-if="isShowPlugins(props.plugins, 'del')" class="preview-list__btn-item" >
              <el-icon><Delete @click="handleRemove(file)" /></el-icon>
            </span>
          </div>
          <!-- 图片本尊 -->
          <img class="preview-list__image" :src="getImageUrl(file)" />
        </div>
      </template>
    </el-upload>
    <!-- 预览图片组件 -->
    <MyPreviewImage v-model:show="previewShow" :imgSrc="currentImageSrc" />
  </div>
</template>
<style lang="less" scope>
.my-upload-image__component {
  .avatar-uploader {
    &.circle{
      .el-upload-list .el-upload-list__item {
        border-radius: 99999px;
      }
    }
    .el-upload-list {
      &.is-disabled {
        .el-upload {
          display: none;
        }
      }
      .el-upload-list__item {
        width: auto;
        height: auto;
      }
    }
    .el-upload {
      width: auto;
      height: auto;
    }
    .preview-list__wrap {
      position: relative;
      overflow: hidden;

      .preview-list__image {
        width: 100%;
        height: 100%;
      }
      &:hover {
        .preview-list__btn-group {
          transform: translateY(0%);
        }
      }
      .preview-list__btn-group {
        position: absolute;
        display: flex;
        width: 100%;
        height: 100%;
        align-items: center;
        justify-content: space-around;
        background: rgba(0, 0, 0, 0.3);
        transition: all 0.3s;
        transform: translateY(-100%);
        .preview-list__btn-item {
          color: #fff;
          font-size: inherit;
          cursor: pointer;
          transition: all 0.3s;
          &:hover {
            transform: scale(1.5);
          }
        }
      }
    }
  }
}
</style>

在这里插入图片描述
依此组件的复杂程度,可以玩出几种花样:

1.即时上传图片
上面更新用户信息已经用过了,不做讲述

2.手动上传图片

<script lang="ts" setup>
import MyUploadImage from "@/components/my-upload-image.vue";
const myUploadImageRef = ref<any>();
const fileList = ref([]);

const updateUserName = async () => {
  const r = await myUploadImageRef.value.execute();
};
</script>
<template>
	<MyUploadImage
	   ref="myUploadImageRef"
	   v-model:fileList="fileList"
	   :size="[50, 50]"
	   circle
	   :plugins="['preview', 'del']"
	   pluginsSize="14px"
	   :immediate="false" />
   <el-button type="primary" @click="updateUserName">提交</el-button>
</template>

3.数据库不删除图片

<script lang="ts" setup>
import MyUploadImage from "@/components/my-upload-image.vue";
const fileList = ref([]);

const f = (path: string) => {
  return new Promise((resolve) => resolve(1));
};
</script>
<template>
	<MyUploadImage
	   v-model:fileList="fileList"
	   :size="[50, 50]"
	   circle
	   :plugins="['preview', 'del']"
	   pluginsSize="14px"
	   :removeApi="f" />
</template>

MyPreviewImage自定义预览图片组件

<!-- /src/components/my-preview-image.vue -->
<script lang="ts" setup>
import { withDefaults, ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
interface Props {
  show: boolean;
  imgSrc: string;
  canMove?: boolean;
  canScale?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  show: false,//组件显隐
  imgSrc: "",//图片地址
  canMove: true,//是否可移动
  canScale: true,//是否可缩放
});
const emits = defineEmits(["update:show"]);
const dragmaskShow = ref(false);//拖拽蒙版的显隐
//用于存储图片的css transform值的变量
const transform = reactive({ translateX: 0, translateY: 0, scale: 1 });
/** 关闭预览组件的方法 */
const closePreview = () => {
  emits("update:show", false);
};
/** 鼠标滚轮滚动事件触发的方法:改变图片的大小 */
const changeImageSize = (e: WheelEvent) => {
  if (!props.canScale) return;
  if (e.wheelDeltaY > 0) {//向上滚:扩大倍数边界为10
    transform.scale < 10 && (transform.scale += 0.1);
  } else {//向下滚:缩小倍数边界为0.2
    transform.scale > 0.2 && (transform.scale -= 0.1);
  }
};
/** 鼠标移动事件触发的方法 */
const mouseMove = (e: MouseEvent) => {
  if (!props.canMove) return;
  transform.translateX += e.movementX;
  transform.translateY += e.movementY;
};
/** 监听该组件一旦重新开启,则重置css transform的值 */
watch(props, (newValue: boolean) => {
    if (newValue.show) {
      transform.translateX = 0;
      transform.translateY = 0;
      transform.scale = 1;
    }
  },
  { immediate: true }
);
</script>
<template>
  <Teleport to="body">
    <!--@wheel是Fixfox浏览器的滚轮事件 @mousewheel是其他浏览器的滚轮事件 -->
    <div
      @mouseup="closePreview"
      @mousewheel.prevent="changeImageSize"
      @wheel.prevent="changeImageSize"
      v-if="props.show"
      class="my-preview-image__component">
      <!-- @dragstart.prevent是为了取消拖拽图片的默认行为(Fixfox浏览器) -->
      <img
        class="animate__animated animate__zoomIn"
        @dragstart.prevent
        @mousedown.stop="dragmaskShow = true"
        @mouseup.stop="dragmaskShow = false"
        :style="{
          transform: `translate(${transform.translateX}px,${transform.translateY}px ) scale(${transform.scale})`,
        }"
        @click.stop
        :src="props.imgSrc" />
        <!-- 点击预览图片出现在图片上面的透明蒙版 -->
      <div
        @click.stop
        @mousedown.stop="dragmaskShow = true"
        @mouseup.stop="dragmaskShow = false"
        v-show="dragmaskShow"
        @mousemove="mouseMove"
        class="drag-mask"
        :class="{ move: props.canMove }"></div>
    </div>
  </Teleport>
</template>
<style lang="less" scope>
.my-preview-image__component {
  --animate-duration: 0.3s;
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  z-index: 99999;
  background: rgba(0, 0, 0, 0.3);
  .drag-mask {
    position: fixed;
    z-index: 100000;
    inset: 0;
    &.move {
      cursor: grab;
    }
  }
}
</style>

在这里插入图片描述

MyDeleteMessageBox删除提示组件

<!-- /src/components/my-delete-message-box.vue -->
<script lang="ts">
import { h, ref } from "vue";
import { ElMessageBox, ElCheckbox } from "element-plus";
const messageBoxHidden: any = {};//用于存储已选择【不再提醒】的地方
interface MyDeleteMessageBoxType {
  autofocus?: boolean;
  title?: string;
  icon?: string;
  customClass?: string;
  customStyle?: { [propName: string]: any };
  showConfirmButton?: boolean;
  showCancelButton?: boolean;
  confirmButtonText?: string;
  cancelButtonText?: string;
  roundButton?: boolean;
  closeOnClickModal?: boolean;
  closeOnPressEscape?: boolean;
  buttonSize?: string;
  distinguishCancelAndClose?: boolean;
  showClose?: boolean;
  draggable?: boolean;
  message?: string;
}
/** 注意!该组件直接暴露出去MyDeleteMessageBox方法以供外部使用
*** key 相当于id,用于存储选择【不在提醒】的地方
*** opt 配置项
*/
export const MyDeleteMessageBox = (key = "default", opt: string | MyDeleteMessageBoxType = {} ) => {
  //与ElMessageBox组件属性保持一致
  const defaultOptions = {
    autofocus: false,
    title: "提示",
    icon: "",
    customClass: "",
    customStyle: {},
    showConfirmButton: true,
    showCancelButton: true,
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    roundButton: false,
    closeOnClickModal: false,
    closeOnPressEscape: true,
    buttonSize: "default",
    distinguishCancelAndClose: true,
    showClose: true,
    draggable: false,
    message: "确定删除?",
  };
  const option = {};
  if (typeof opt === "string") {
    Object.assign(option, { ...defaultOptions, message: opt });
  } else {
    Object.assign(option, { ...defaultOptions, ...opt });
  }
  const check = ref(false);//【不再提醒】是否勾选
  if (messageBoxHidden[key]) {
    //如果该key的本组件已经选择过了【不再提醒】并【确定】,则直接通过
    return Promise.resolve("confirm");
  } else {
    //否则,弹出删除前提示
    return ElMessageBox({
      ...option,
      message: () =>
        h("div", null, [
          h("div", null, option.message),
          h("div", { style: "margin-top:8px;" }, [
            h(ElCheckbox, {
              label: "不再提醒",
              size: "small",
              modelValue: check.value,
              onChange: (value: boolean) => {
                check.value = value;
              },
            }),
          ]),
        ]),
    }).then((action: string) => {
        messageBoxHidden[key] = check.value;
        return Promise.resolve(action);
    }).catch(() => {
        return Promise.reject();
    });
  }
};
</script>

在这里插入图片描述

后端

数据库表的修改

在原基础上,create_user用户表需要新增用户头像列、用户签名列和用户昵称列:

ALTER TABLE create_user ADD COLUMN (
avatar VARCHAR(60) COMMENT '用户头像' DEFAULT "",
signature VARCHAR(100) COMMENT '用户签名' DEFAULT "",
nickname VARCHAR(10) COMMENT '用户昵称' DEFAULT ""
);

在这里插入图片描述

获取用户信息接口

以下代码只展示新增的代码

/** /routes/user.js */
const { lookUserInfo } = require("../module/user");
module.exports = [
  {
    url: "/userInfo",
    methods: "get",
    actions: lookUserInfo,
  }
];
/** /module/user.js */
/** 查看用户信息 */
async function lookUserInfo(ctx, next) {
  const params = ctx.request.query;
  const sql = `SELECT username,DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s') as create_time,mobile,sex,email,DATE_FORMAT(birth,'%Y-%m-%d') as birth,avatar,signature,nickname FROM create_user WHERE username='${params.username}'`;
  try {
    const v = await ctx.db.query(sql);
    if (v) {
      ctx.body = {
        code: 200,
        message: "查询成功",
        data: v[0],
      }
    } else {
      ctx.body = {
        code: 2,
        message: "未查询到信息",
        data: {},
      }
    }
  } catch (e) {
    ctx.response.status = 500;
    ctx.body = { message: e, code: 99, data: {} };
  }
}
module.exports = {
  lookUserInfo
};

更新用户信息

/** /routes/user.js */
const { updateUserInfo } = require("../module/user");
module.exports = [
  {
    url: "/updateUserInfo",
    methods: "post",
    actions: updateUserInfo
  }
];
/** /module/user.js */
/** 更新用户信息 */
async function updateUserInfo(ctx, next) {
  //create_user表可修改的字段
  const columns = ['sex', 'email', 'birth', 'avatar', 'signature', 'nickname']
  const params = ctx.request.body;
  const filterColumns = columns.filter(item => item in params)
  const setSql = filterColumns.map(item => `${item}='${params[item]}'`).join(',')
  const sql = `UPDATE create_user SET ${setSql} WHERE username='${params.username}'`
  const selectSql = filterColumns.map(p => {
    if (p === 'birth') return `DATE_FORMAT(birth,'%Y-%m-%d') as birth`;
    return p
  }).join(',')
  const sql2 = `SELECT ${selectSql} FROM create_user WHERE username='${params.username}'`
  try {
    //更新用户表
    await ctx.db.query(sql);
    //查找用户表
    const r = await ctx.db.query(sql2);
    ctx.body = {
      code: 200,
      message: "成功",
      data: r?r[0]:""
    }
  } catch (e) {
    ctx.response.status = 500;
    ctx.body = { message: e, code: 99, data: {} };
  }
}
module.exports = {
  updateUserInfo
};

写得着实拥挤,有种千军踏马飞驰过,留下一叶尘沙的感觉。本篇文章【更新用户信息】功能只是一个终点,其过程才是我想表达的,但又发散的太多,很难突出其重点,总之,让它留在我的文件夹里吃灰吧。

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值