本篇为实战篇,干货不多。
基于上篇实现的上传图片接口,可以实现下更新用户信息功能,实现后的前端效果如下图:
前端
顶部区域
代码如下:
<!-- /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
};
写得着实拥挤,有种千军踏马飞驰过,留下一叶尘沙的感觉。本篇文章【更新用户信息】功能只是一个终点,其过程才是我想表达的,但又发散的太多,很难突出其重点,总之,让它留在我的文件夹里吃灰吧。