1 主页面布局
2 左侧菜单栏实现
创建sfc(单文件组件):
/component/home/XfHomeViewSideMenuBar.vue
用于封装定制的ElMenu组件构建左侧菜单栏。
2.1获取菜单数据
2.1.1 菜单数据
在左侧菜单栏展示的是依据当前登录用户所有拥有的访问权限的type属性为1的动态菜单数据动态构建菜单及系统公用的静态菜单;
在数据库中保存的菜单数据的type属性的属性值可为,1:菜单;2:按钮;3:其它(比如下拉菜单等。)
2.1.2 获取菜单数据
在动态构建路由阶段已从服务器获取了当前登录用户所有拥有访问权限的菜单数据,并保存在vuex的store.state.menus属性中,此时从该属性中获取对应数据即可。具体实现为:
- 在当前组件的
<sricpt setup></script>
中:
<script lang="ts" setup>
import {useStore} from "vuex";
import {onMounted, ref, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
import {convertNode, notifyBox} from "@/plugins/api";
import $emitter from "@/plugins/mitt";
/*================================HOOK函数==================================*/
const $store = useStore();
const $router = useRouter();
const $route = useRoute();
/*================================变量声明==================================*/
// 变量menusData用于保存从vuex中获取菜单数据。
const menusData = $store.state.menus;
2.2 构建用于构建菜单的数据
2.2.1 构建全局的数组转节点的工具方法
在大部UI组件库中都需要使用节点结构的数据渲染页面UI,而服务端查询数据库后结果为List,响应给前端时为一个数组,要正确渲染需要节点结构的数据的UI时,需要在服务端将List转换成node结构或在客户端将Array转换成node结构。
服务端转换与客户端转换的选择:考虑到服务器端为所有用户所共用,而客户端则为当前用户单独使用,服务器与客户端都能处理的逻辑,一般由客户端处理。
2.2.1.1 数组转换节点结构的实现要点
实现要点:
- 不论是java还是js,引用类型的变量保存是实际对象的地址,操作该变量修改对应属性的值时,实际对象也会修改,同时该对象的其它引用变量也会同时修改,原因:他们使用的是同一内存数据。
- 要想实现数组向节点结构转换,一般要有两个属性:id与parentId或与之相同意义的属性,id属性保存当前对象的id值,parentId属性保存当前对象的父节点对象的id。而parentId为null|undefined时,则该对象为根节点。
2.2.1.2 数组转节点工具方法实现
在/plugins/api.ts中定义:
// 数组转节点
export function convertNode(source: any[]) : any[]
{
let tempMap = new Map();
source.forEach((item:any) => {
tempMap.set(item.id, item);
})
// @ts-ignore
let target = [];
source.forEach((item:any) => {
let parent = tempMap.get(item.parentId);
if(!parent)
{
target.push(item);
return;
}
if(!parent.children)
parent.children = [];
parent.children.push(item);
});
// @ts-ignore
return target;
}
2.2.2 构建用于构建菜单栏的菜单数据
- 实现要点:
- 过滤用户所有拥有访问权限的菜单数据,并删除非必需的属性,得到菜单数据数组。
- 将菜单数据数组转换成节点结构。
- 声明ElMenu双向绑定变量
- 具体实—在
XfHomeViewSideMenuBar.vue
的<sricpt setup></script>
中定义:
/*generateSideMenus方法用于构建渲染ElMenu所需要的菜单数据*/
function generateSideMenus(source: []): any[] | void
{
if(!Array.isArray(source))
{
$router.push("/login").then(() => {
notifyBox("系统初始化失败,请求稍后重试!", "error");
});
return;
}
let temp = source.filter((item:any) => item.type === 1).map((item:any) => {
let menu:any = {};
menu.id = item.id;
menu.parentId = item.parentId;
menu.name = item.name;
menu.path = item.path;
menu.iconClass = item.iconClass;
return menu;
})
return convertNode(temp);
}
/*ElMenu双向绑定变量*/
let menus = ref(generateSideMenus(menusData));
2.3 渲染左侧菜单栏UI
2.3.1 渲染ElMenu
- 实现要点
- 通过v-for指令渲染ElMenu
- 通过v-for动态添加的ElMenuItem和ElSubMenu元素,注意添加key属性,属性值尽量不使用通过v-for获取的index设置。
- 要使用ElMenu具备路由功能,需要给ElMenu添加router属性。
- 具体实现—在
XfHomeViewSideMenuBar.vue
的<template></template>
中定义:
<template>
<el-menu :default-active="defaultActive" text-color="#fff" router unique-opened :collapse="collapse" mode="vertical">
<div class="title-container">
<img class="logo-img" src="../../assets/logo.png" alt="logo.png" >
<div class="title">旭锋集团运营管理平台</div>
</div>
<template v-for="item in menus">
<!--构建menu-item: 此类菜单项无子菜单-->
<el-menu-item v-if="!item.children" :index="item.path" :key="item.id">
<i :class="item.iconClass" class="icon-margin"></i>
{{item.name}}
</el-menu-item>
<!--构建sub-menu:此类菜单项带有子菜单-->
<el-sub-menu v-else :key="`first::${item.id}`">
<template #title>
<i :class="item.iconClass" class="icon-margin"></i>
{{item.name}}
</template>
<el-menu-item v-for="it in item.children" :index="it.path" :key="it.id">
<i :class="it.iconClass" class="icon-margin"></i>
{{it.name}}
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</template>
2.3.2 定制ElMenu
ElMenu提供了
background-color
、text-color
、active-text-color
三个属性,用于简单设置ElMenu样式。如果要定制ElMenu样式,则需要如下class样式:.el-menu; 竖向ElMenu的.el-menu–vertical。在覆盖了以上两种class样式,则需要自定义ElMenu折叠与展开样式。具体实现如下— 在当前组件的<style lang="scss"></style>
定义如下:
/*通过该class设置背景色透明,原因:对于SubMenu实现,实际上是在ElMenu中嵌套了一个ElMenu*/
.el-menu {
background: rgba(0, 0, 0, 0);
}
/*整体菜单栏带有两个class, 一个.el-menu和.el-menu--vertical,而SubMenu中嵌套的ElMenu则仅有一个class,即el-menu*/
.el-menu--vertical {
user-select: none;
background-image: linear-gradient(115deg , #000046 40%, #1CB5E0);
overflow: hidden;
}
/*覆盖了.el-menu和.el-menu--vertical时,需要自己提供展开与折叠时的样式,如下所示:*/
.el-menu--vertical:not(.el-menu--collapse){
width: 13vw;
}
.el-menu--vertical:is(.el-menu--collapse){
width: 2.6vw;
}
.el-menu-item, .el-sub-menu__title {
/*width: 100%;*/
height: 4.3vh!important;
font-size: 0.74vw;
}
/*通过.el-menu-item:hover和.el-sub-menu__title:hover两个伪装选择器定义鼠标悬浮时的样式*/
.el-menu-item:hover, .el-sub-menu__title:hover{
background: #000046; /* fallback for old browsers */
background: -webkit-linear-gradient(to right, #000046, #1CB5E0); /* Chrome 10-25, Safari 5.1-6 */
background: linear-gradient(to right, #000046, #1CB5E0); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
border-left: #9900CC 2px solid;
}
/*设置系统logo与标题的样式*/
.title-container{
box-sizing: border-box;
padding: 0 0.7vw;
height: 4.5vh;
line-height: 4.5vh;
display: flex;
justify-content: flex-start;
align-items: center;
overflow: hidden;
}
.logo-img{
display: block;
width: 1.5vw;
height: 3.2vh;
margin-top: 0.4vh;
}
.title{
height: 4.3vh;
line-height: 4.3vh;
margin-left: 0.4vw;
/*文本渐变色*/
background: linear-gradient(to right, #37998D, #1fddff);
-webkit-background-clip: text;
color: transparent;
font-size: 0.9vw;
font-weight: bolder;
/*text-shadow: 0 0 0.1vh #B2A12D;*/
}
.icon-margin{
margin-right: 0.8vw;
}
2.4 设置默认激活状态的ElMenuItem
ElMenu提供了
default-active
属性,用于设置默认激活状态的ElMenuItem, 其属性值为想要设置为ElMenuItem的index属性值。而开启路由模式的ElMenu,路由信息对象的path属性即为ElMenu的index属性值。
- 实现要点:
- 校验当前路由信息对象的type是否为1,
- 如果为1,将当前路由信息对象的path属性值设置给ElMenu的
default-active
属性。 - 如果不为1,则递归查找其祖先菜单中第一个type为1的菜单数据,将其path属性设置给ElMenu的
default-active
属性。
- 如果为1,将当前路由信息对象的path属性值设置给ElMenu的
- 监控路由变化(本系统中存在多种方式触发路由跳转,因此需要监控路由变化),当路由变化时,执行上述过程将指定的path属性设置给ElMenu的
default-active
属性。
- 校验当前路由信息对象的type是否为1,
- 具体实现:在
XfHomeViewSideMenuBar.vue
的<sricpt setup></script>
中定义
定义工具方法
getDefaultActiveMenuIndex
获取符合要求的path值
/*getDefaultActiveMenuIndex方法:用于计算默认激活ElMenuItem的index*/
function getDefaultActiveMenuIndex(path:string):string | undefined
{
let currentMenu = menusData.find((item:any) => item.path === path);
if(!currentMenu)
return;
if(currentMenu.type === 1)
return path;
let parentMenu = menusData.find((item:any) => item.id === currentMenu.parentId);
return getDefaultActiveMenuIndex(parentMenu.path);
}
声明与ElMenu的
default-active
属性双向绑定变量:
/*defaultActive:保存当前路由对应ElMenuItem元素的index值
* 如果触发路由的非ElMenu元素:即由按钮触发的页面跳转,则,该变量的值为按钮对于的上级为ElMenuItem元素的index值*/
let defaultActive = ref(getDefaultActiveMenuIndex($route.path));
监控路由变化, 设置
default-active
属性值
watch($route, (to:any, from:any) => {
// @ts-ignore
defaultActive.value = getDefaultActiveMenuIndex(to.path);
})
2.5 控制菜单栏折叠与展开
由于左侧菜单栏折叠与展开控制按钮位于其兄弟组件
XfHomeViewHeader.vue
文件中,因此要实现左侧菜单栏折叠与展开,需要通过vue组件间传参技术中兄弟组件传参实现。而vue兄弟组件传参常用方式:事件总线(EventBus),Vue2中的vue实例本身即为EventBus,而Vue3中移除了该功能,需要安装独立组件mitter实现EventBus。
- 实现要点:
- ElMenu添加collapse属性,并双向绑定变量。
- 在当前组件挂载完毕后,注册事件监听器,监听折叠与展开事件。
- 具体实现:
plugins/api.ts中添加折叠与展开事件标识key的系统变量
// 系统常量
export const systemConst = {
/*sessionStorage保存当前登录用户数据的key*/
currentUser: "current_user",
/*sessionStorage保存access_token数据的key*/
accessToken: "access_token",
/*sessionStorage保存refresh_token数据的key*/
refreshToken: "refresh_token",
/*sessionStorage保存token_type数据的key*/
tokenType: "token_type",
/*折叠与展开事件标识key*/
collapseKey: "COLLAPSE_MENU_BAR",
}
当前组件
<sricpt setup></script>
中定义
/*菜单栏折叠与展开控制变量*/
let collapse = ref(false);
/*注册折叠与展开事件监听器*/
onMounted(()=>{
$emitter.on(systemConst.collapseKey, res => {
// @ts-ignore
collapse.value = res;
})
})
3 顶部header栏实现
3.1 面包屑导航实现
构建
component/header/XfHeaderBreadcrumb.vue
组件,用于封装ElBreadcrumb组件,根据当前路由信息信息对象,渲染ElBreadcrumb。
- 业务逻辑:
- 根据遍历所有用户拥有访问权限的菜单数组,过滤获取所有带有path属性菜单数据,并删除面包屑不需要的属性。
- 遍历上述数组,递归从原始数据数组中获取对应的所有祖先菜单及该路由对应的菜单数据构建的菜单数组。
- 构建以所有路由信息对象的path为key,及递归获取数组为value的map
- 通过当前路由信息对象的path属性,获取对应的菜单数组,
- 通过该菜单数组,渲染ElBreadcrumb
- 监听路由变化,重新渲染ElBreadcrumb
- 初始化当前组件实例:
- 在当前组件
<sricpt setup></script>
中定义
- 在当前组件
<script lang="ts" setup>
import {useRoute} from "vue-router";
import {useStore} from "vuex";
import {ref, watch} from "vue";
import { ArrowRight } from '@element-plus/icons-vue'
/*================================HOOK函数==================================*/
const $route = useRoute();
const $store = useStore();
</script>
3.1.1 获取菜单数据数组
前置需求:
- 从vuex中获取当前用户拥有访问权限的菜单数据。
- 该过程可以在当前vuex实例的生命周期
setup()
中获取, 也可以在后续获取数据数组的工具方法中获取。两种实现位置选择:- 在setup()生命周期中获取时,只获取一次,后续使用时不在重复获取,优点:少一句代码执行,提高效率,缺点:至始至终内存中都有一份该数组,浪费了内存。
- 在工具方法中实现时,每次路由变化都会从vuex中获取该数据,在方法结束后, 该数组将被释放。优点:节省内存空间;缺点:多一句代码执行,牺牲运行效率。
- 考虑到左侧菜单栏也要获取该菜单数组,选择后者在工具方法中获取
- 该过程可以在当前vuex实例的生命周期
3.1.1.1 定义构建面包屑数据的工具方法
- 实现要点:
- 需要通过递归查找当前路由信息对象对应菜单数据的所有祖先菜单数据。
- 具体实现:在当前组件
<sricpt setup></script>
中定义
/*构建渲染面包屑数据工具方法:通过某一菜单数据,获取该菜单的所有祖先菜单数据与该菜单数据构成的数组*/
function generateBreadcrumbs(sourceMap: Map<number, any>, item: any, target: any[])
{
let parent = sourceMap.get(item.parentId);
if(parent)
{
generateBreadcrumbs(sourceMap, parent, target)
}
target.push(item);
}
3.1.1.2 定义构建面包屑数据方法,并定义变量保存返回值
- 具体实现:在当前组件
<sricpt setup></script>
中定义
/*构建渲染面包屑数据方法:返回以菜单path属性为key, 工具方法构建数组为value的Map*/
function builderBreadcrumbs():Map<string, any> | undefined
{
const hasMenus = $store.state.menus;
if(!hasMenus || !Array.isArray(hasMenus) || hasMenus.length <= 0)
return;
let crumbs = hasMenus.filter((item:any) => item.type === 1 || item.path ).map((it:any) => {
let crumb:any = {};
crumb.name = it.name;
crumb.path = it.path;
crumb.id = it.id;
crumb.parentId = it.parentId;
return crumb;
});
let menuMap = new Map();
crumbs.forEach((item:any) => {
menuMap.set(item.id, item);
});
let crumbsMap = new Map();
crumbs.filter((item:any) => item.path).forEach((it:any) => {
let crumbArr:any[] = [];
generateBreadcrumbs(menuMap, it, crumbArr);
crumbsMap.set(it.path, crumbArr);
})
return crumbsMap;
}
// 在setup()生命周期中,构建该Map,并保存
const crumbMap = builderBreadcrumbs();
3.1.2 渲染ElBreadcrumb
- 声明绑定ElBreadcrumb双向绑定的变量并初始化
- 在当前组件
<sricpt setup></script>
中定义:
- 在当前组件
// 获取当前路由信息对应的面包屑数据
let currentBreadcrumb = ref(crumbMap?.get($route.path));
- 渲染ElBreadcrumb
- 在当前组件中的
<template></template>
实现
- 在当前组件中的
<template>
<el-breadcrumb class="xf-header-bread-crumb" :separator-icon="ArrowRight">
<el-breadcrumb-item to="/home"><span class="crumb-name">主页</span></el-breadcrumb-item>
<el-breadcrumb-item v-for="item in currentBreadcrumb" :key="item.id" :to="item.path"><span class="crumb-name">{{item.name}}</span></el-breadcrumb-item>
</el-breadcrumb>
</template>
- 设置面包屑样式
- 在当前组件的
<style scoped></style>
实现
- 在当前组件的
.xf-header-bread-crumb{
width: 100%;
height: 4.3vh;
line-height: 4.3vh;
}
.crumb-name{
color: #eeeeee;
}
3.2 用户中心组件实现
构建
component/header/XfHeaderUserCenter.vue
用于封装antdv的Dropdown组件
3.2.1 初始化当前vue实例
- 具体实现:
- 在当前组件的
<sricpt setup></script>
中实现
- 在当前组件的
<script lang="ts" setup>
import {ref} from "vue";
import { DownOutlined, PoweroffOutlined, BankOutlined } from '@ant-design/icons-vue';
import {useRouter} from "vue-router";
import {notifyBox, systemConst} from "@/plugins/api";
/*================================HOOK函数==================================*/
const $router = useRouter();
/*================================变量声明==================================*/
// @ts-ignore
const currentUser = JSON.parse(sessionStorage.getItem(systemConst.currentUser)) ;
let avatarUrl = ref(currentUser.userFace? currentUser.userFace : 'https://cube.elemecdn.com/9/c2/f0ee8a3c7c9638a54940382568c9dpng.png');
let currentUserName = ref(currentUser.username);
/*================================当前页面工具方法===========================*/
/*================================UI初始化方法==============================*/
/*================================其它生命周期方法==========================*/
/*================================核心业务方法==============================*/
// @ts-ignore
function onClick({key})
{
switch (key)
{
case "center":
console.log("要进入用户中心了!");
break;
case "logout":
$router.push("/login").then(()=>{
notifyBox("您已安全退出本系统,祝您生活愉快,再见!");
});
break;
}
}
</script>
3.2.2 渲染UI
- 渲染Dropdown
- 在当前组件中的
<template></template>
实现
- 在当前组件中的
<template>
<a-dropdown :trigger="['click']" placement="bottomRight">
<a>
<a-avatar shape="square" :src="avatarUrl" size="small"></a-avatar>
{{currentUserName}}<down-outlined style="margin-left: 0.2vw"/>
</a>
<template #overlay>
<a-menu @click="onClick" >
<a-menu-item key="center"><bank-outlined class="icon-hor-space"/>用户中心</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout"><poweroff-outlined class="icon-hor-space"/>安全退出</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
3.3 消息摘要展示组件实现
构建
component/header/XfHeaderMsg.vue
组件,用于封装antdv中Badage(角标)组件和自定义消息展示区div
- 实现要点:
- 该组件需要的消息数据是由其祖先组件
HomeView.vue
提供的,并且,该组件需要对消息数据的操作要反馈给HomeView.vue
组件。即需要祖先组件与当前组件进行响应式传参。 - 祖先组件与当前组件响应式传参实现方式之一:provider/inject; 要求:provider提供的变量需要是ref/reactive的,否则不能实现响应式。
- 监听inject获取的数据时,监听对象是
数据.value
- 该组件需要的消息数据是由其祖先组件
- 组件初始化实现:
<template>
<div class="xf-header-msg">
<a-badge :count="count">
<i class="fa-solid fa-envelope msg-icon"></i>
</a-badge>
<div class="msg-content-container">
<a-tabs v-model:activeKey="activeKey" centered @change="tabsChange">
<a-tab-pane :key="systemConst.msgType">
<template #tab>
<a-badge :count="msgCount" :offset="['3px', '-3px']">
消息
</a-badge>
</template>
</a-tab-pane>
<a-tab-pane :key="systemConst.noticeType">
<template #tab>
<a-badge :count="noticeCount" :offset="['3px', '-3px']">
通知
</a-badge>
</template>
</a-tab-pane>
<a-tab-pane :key="systemConst.afficheType">
<template #tab>
<a-badge :count="noticeCount" :offset="['3px', '-3px']">
公告
</a-badge>
</template>
</a-tab-pane>
</a-tabs>
<template v-if="!showArr.length">
<a-empty image="https://gw.alipayobjects.com/mdn/miniapp_social/afts/img/A*pevERLJC9v0AAAAAAAAAAABjAQAAAQ/original"
:image-style="{height: '60px',}">
<template #description>
<span>
{{ notifyContent }}
</span>
</template>
</a-empty>
</template>
<template v-else v-for="item in showArr">
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import {inject, ref, watch} from "vue";
import {systemConst} from "@/plugins/api";
/*================================HOOK函数==================================*/
/*================================变量声明==================================*/
// @ts-ignore
let msgArr = inject("msgArr");
// @ts-ignore
let count = ref(msgArr.value.length);
let activeKey = ref(1);
let showArr = ref(getCurrentShowData(1));
let msgCount = ref(getCurrentShowData(1).length);
let noticeCount = ref(getCurrentShowData(2).length);
let afficheCount = ref(getCurrentShowData(3).length);
let notifyContent = ref("当前无消息");
/*================================当前页面工具方法===========================*/
/*================================UI初始化方法==============================*/
function getCurrentShowData(type: number)
{
// @ts-ignore
return msgArr.value.filter(item => item.type === type);
}
/*================================其它生命周期方法及其它组合API==========*/
// @ts-ignore
watch(msgArr.value, (to, from) => {
count.value = to.length;
msgCount.value = getCurrentShowData(systemConst.msgType).length;
noticeCount.value = getCurrentShowData(systemConst.noticeType).length;
afficheCount.value = getCurrentShowData(systemConst.afficheType).length;
showArr.value = getCurrentShowData(activeKey.value);
})
/*================================核心业务方法==============================*/
function tabsChange(activeKey:number)
{
showArr.value = getCurrentShowData(activeKey);
switch (activeKey)
{
case 1:
if(!showArr.value.length)
notifyContent.value = "当前无消息";
break;
case 2:
if(!showArr.value.length)
notifyContent.value = "当前无通知";
break;
case 3:
if(!showArr.value.length)
notifyContent.value = "当前无公告";
break;
}
}
function deleteMsg()
{
// @ts-ignore
msgArr.value.pop();
}
</script>
<style >
.xf-header-msg{
position: absolute;
right: 0;
top: 0.75vh;
}
.msg-icon {
font-size: 1.1vw;
color: #C0C4CC;
transition: all 50ms 0ms linear;
}
.xf-header-msg:hover{
cursor: pointer;
}
.xf-header-msg:hover .msg-icon{
font-size: 1.2vw;
color: blue;
}
.msg-content-container{
display: none;
background-color: #fff;
width: 20vw;
min-height: 20vh;
max-height: 55vh;
overflow: auto;
position: absolute;
right:0;
top:3vh;
z-index: 1;
box-shadow: 0 0 0.5vh 0.1vh dodgerblue;
color: #C0C4CC;
}
.xf-header-msg:hover .msg-content-container{
display: block;
}
</style>
3.4 顶部header实现
构建
component/home/XfHomeViewHeader.vue
组件
- 实现要点
- 该组件中定义的菜单栏折叠与展开按钮,需要实现header组件与menu组件兄弟间传参。即定义触发mitter自定义事件。
- 具体实现
<template>
<a-row class="xf-home-view-header" type="flex" justify="space-between" algin="center">
<a-col :span="12">
<a-row type="flex">
<a-col class="collapse-icon-container" :span="1">
<i v-show="!isCollapse" class="fa-solid fa-outdent collapse-icon" @click="iconClkEvent"></i>
<i v-show="isCollapse" class="fa-solid fa-indent collapse-icon" @click="iconClkEvent"></i>
</a-col>
<a-col :span="8">
<xf-header-breadcrumb/>
</a-col>
</a-row>
</a-col>
<a-col :span="12">
<a-row type="flex" justify="end" algin="center">
<a-col :span="1" class="msg-container">
<xf-header-msg/>
</a-col>
<a-col :span="3">
<div class="user-option-container">
<xf-header-user-center/>
</div>
</a-col>
</a-row>
</a-col>
</a-row>
</template>
<script lang="ts" setup>
import {ref} from "vue";
import $emitter from "@/plugins/mitt";
import XfHeaderUserCenter from "@/components/home/header/XfHeaderUserCenter.vue";
import XfHeaderBreadcrumb from "@/components/home/header/XfHeaderBreadcrumb.vue";
import XfHeaderMsg from "@/components/home/header/XfHeaderMsg.vue";
import {systemConst} from "@/plugins/api";
/*================================HOOK函数==================================*/
/*================================变量声明==================================*/
let isCollapse = ref(false);
/*================================当前页面工具方法===========================*/
/*================================UI初始化方法==============================*/
function iconClkEvent()
{
isCollapse.value = !isCollapse.value;
$emitter.emit(systemConst.collapseKey, isCollapse.value)
}
/*================================其它生命周期方法==========================*/
/*================================核心业务方法==============================*/
</script>
<style lang="scss">
.xf-home-view-header{
user-select: none;
box-sizing: border-box;
color: #ffffff;
height: 4.3vh;
line-height: 4.3vh;
background-image: linear-gradient(115deg , #000046, #1CB5E0, #fff);
padding: 0 1vw!important;
}
.collapse-icon-container{
height: 4.3vh;
line-height: 4.3vh;
font-size: 1vw;
}
.collapse-icon{
transition: all 50ms 0ms linear;
}
.collapse-icon:hover{
font-size: 1.05vw;
cursor: pointer;
color: #1fddff;
}
.user-option-container{
float: right;
}
.msg-container{
position: relative;
}
</style>
4 页面标签页实现
构建
component/home/header/XfUtilsBar.vue
,用于封装一组antdv的a-tag元素。每一个a-tag元素对应一个打开过的页面,包含了该页面对应的路由信息对象。当用户点击某一a-tag时,将根据该a-tag包含的路由信息触发路由跳转。
- 实现要点:
- 该组件默认最多显示20个a-tagUI;
- 第一个a-tag对应的页面默认始终为工作台对应的页面。原因:用户登录系统后默认重定向到工作台页面。
- 当前vue实例创建后,从localStorge中获取保存的路由信息数组
- 如果没有,则以工作台页面对应的路由创建第一个标签对应的路由信息,并保存到渲染UI的数组中。
- 如果有,则将保存的路由信息数组保存到渲染UI的数组中,并删除localStorage中的数组数据。
- 监听当前路由变化,当路由发生变化时,
- 校验当前路由信息数组中,是否存在当前路由信息path对应的路由信息对象。
- 如果不存在,则:
- 校验当前a-tag对应的路由信息数组中的元素个数是否多于20个。
- 如果大于等于20个,将根据最近最不常使用策略,删除路由信息数组中对应的路由信息。
- 如果小于20个,将通过当前路由信息对象构建a-tag对象的路由信息对象path,title,query属性,并添加到对应路由信息数组中。
- a-tag对应的路由信息对象,除通过路由信息对象的path, meta.title, query属性初始化的path,title,query属性外,还包括color、timestamp两个属性。color属性用于标识a-tag是否是当前页面对应的a-tag,timestamp属性用于记录当时a-tag最后一次激活时间。
- 校验当前a-tag对应的路由信息数组中的元素个数是否多于20个。
- a-tag对应路由信息对象构建完成或对应路由信息存在,则更新该路由信息对象的
时间戳信息
,并将通过color属性,设置该a-tag为激活状态。
- 如果不存在,则:
- 校验此前激活的a-tag对象是否为当前a-tag。
- 如果不是,通过color属性取消之前激活的a-tag
- 将当前a-tag对象保存到此前激活对象的变量中。
- 校验当前路由信息数组中,是否存在当前路由信息path对应的路由信息对象。
- 当用户点击某一a-tag标签时,
- 通过该标签对象的路由信息,触发路由跳转。
- 当前用户点击关闭可关闭的标签
- 获取该标签对应的路由信息对象在路由信息对象数组中的索引
- 校验关闭标签是否为激活标签
- 如果是,则根据前一个索引对应的路由信息对象触发路由跳转。
- 校验当前索引对应的路由信息对象是否为不可关闭的标签。
-如果不是,将该路由信息对象从数组中移除。
-如果是,则根据最近最少使用策略,移除第二少使用的标签。
- 当用户点击浏览器刷新按钮时,则将当前路由信息数组,保存到localStorage中。
具体实现
- 初始化当前组件
- 在plugins/api.ts中的systemConst对象中添加localStorge中保存当前路由对象数组的key
// 系统常量
export const systemConst = {
/*sessionStorage保存当前登录用户数据的key*/
currentUser: "current_user",
/*sessionStorage保存access_token数据的key*/
accessToken: "access_token",
/*sessionStorage保存refresh_token数据的key*/
refreshToken: "refresh_token",
/*sessionStorage保存token_type数据的key*/
tokenType: "token_type",
/*折叠与展开事件标识key*/
collapseKey: "COLLAPSE_MENU_BAR",
/*消息类型*/
msgType: 1,
noticeType: 2,
afficheType: 3,
/*tagArrayKey*/
tagArrayKey: "tags_array",
}
- 在当前组件的`<sricpt setup></script>`中实现:
<script lang="ts" setup>
import {useRoute, useRouter} from "vue-router";
import {ref, watch} from "vue";
import {systemConst} from "@/plugins/api";
/*================================HOOK函数==================================*/
const $route = useRoute();
const $router = useRouter();
/*================================变量声明==================================*/
</script>
- 初始化路由信息数组及前一个激活标签路由信息数据。
- 在当前组件的
<sricpt setup></script>
中实现:
- 在当前组件的
let tagsArry = [];
if(localStorage.getItem(systemConst.tagArrayKey))
{
// @ts-ignore
tagsArry = JSON.parse(localStorage.getItem(systemConst.tagArrayKey));
localStorage.removeItem(systemConst.tagArrayKey);
}
if(!tagsArry.length)
{
let initTag = {
path: "/dashboard",
title: "工作台",
color: "#108ee9",
timestamp: new Date().getTime()
}
tagsArry.push(initTag);
}
const tags = ref(tagsArry);
let oldTag:any = tags.value.find((item:any) => item.path === $route.path);
- 渲染a-tagUI并设置对应样式
<template>
<div class="xf-home-view-tags-bar">
<a-tag v-for="(item, index) in tags" class="xf-tag" :color="item.color"
:key="item.path" :closable="index !== 0" @click="tagClkEvent(item)" @close="closeCurrentTag(item.path)">{{item.title}}
</a-tag>
</div>
</template>
<style scoped>
.xf-home-view-tags-bar{
display: flex;
width: 100%;
height: 3vh;
background: #ffffff;
box-sizing: border-box;
box-shadow: 0 1vh 1.5vh 0.5vh #222222;
padding: 0 1vw;
align-items: center;
overflow: auto;
}
.xf-tag {
font-size: 0.5vw;
display: flex;
width: 4vw;
height: 2vh;
justify-content: center;
align-items: center;
transition: all 50ms 0ms linear;
}
.xf-tag:hover {
user-select: none;
box-shadow: 0 0 0.2vh 0.1vh #1fddff;
cursor: pointer;
width: 4.2vw;
height: 2.1vh;
}
</style>
- 定义a-tag切换的工具方法
- 在当前组件的
<sricpt setup></script>
中实现:
- 在当前组件的
/*================================当前页面工具方法===========================*/
function changeTag(toRouter:any)
{
let toTags:any = tags.value.find((item:any)=> toRouter.path === item.path);
if(!toTags)
{
if(tags.value.length > 19)
{
let sortTags = JSON.parse(JSON.stringify(tags.value));
sortTags.sort((item1:any, item2:any) => item1.timestamp - item2.timestamp);
let removeIndex = tags.value.findIndex(sortTags[0]);
if(removeIndex === 0)
closeCurrentTag(sortTags[1].path);
else
closeCurrentTag(sortTags[0].path);
}
toTags = {};
toTags.path = toRouter.path;
toTags.title = toRouter.meta.title;
toTags.query = toRouter.query;
tags.value.push(toTags);
}
toTags.color = "#108ee9";
toTags.timestamp = new Date().getTime();
if(toTags.path !== oldTag.path)
oldTag.color = undefined;
oldTag = toTags;
}
/*================================UI初始化方法==============================*/
changeTag($route);
- 监听路由变化
/*================================其它生命周期方法及watch与计算属性==========*/
watch($route, (to, from)=>{
to.path !== "/login" && changeTag(to);
})
- 定义a-tag点击事件处理函数
function tagClkEvent(item:any)
{
$router.push(item);
}
- 定义标签close事件方法
function closeCurrentTag(path:string)
{
let removeIndex = tags.value.findIndex((it:any) => it.path === path);
if(removeIndex !== 0)
{
let remoteTags = tags.value.splice(removeIndex, 1);
if(remoteTags[0] && remoteTags[0].color)
{
$router.push(tags.value[removeIndex - 1]);
}
}
}
- 定义浏览器刷新事件处理方法
window.addEventListener("beforeunload", e =>{
localStorage.setItem(systemConst.tagArrayKey, JSON.stringify(tags.value));
})
5 主页面完整实现
5.1 渲染主页面
- 具体实现
<template>
<el-container>
<xf-home-view-side-menu-bar/>
<el-container>
<xf-home-view-header />
<xf-home-view-tags-bar />
<el-main>
<router-view v-slot="{ Component }">
<transition name="ease">
<component :is="Component" />
</transition>
</router-view>
</el-main>
<el-footer>
<a-row justify="space-between" style="height: 4.3vh; line-height: 4.3vh">
<a-col :span="8">
<span class="message-show">旭峰集团科技制造有限公司</span>
</a-col>
<a-col :span="8" style="align-self: center; " align="center">
<span class="message-show">Copyright ©2022.8.28 UlricaWolfKing All Rights Reserved.</span>
</a-col>
<a-col :span="8" align="end">
<span class="message-show">{{currentDataShow}}</span>
</a-col>
</a-row>
</el-footer>
</el-container>
</el-container>
</template>
<style>
.el-footer {
user-select: none;
box-sizing: border-box;
height: 4.3vh;
line-height: 4.3vh;
padding: 0 1vw!important;
background-image: linear-gradient(115deg , #1CB5E0 50%, #000046);
color: #eeeeee;
font-size: 0.9vw;
}
.el-main {
box-sizing: border-box;
background-color: #E9EEF3;
height: 88.4vh;
padding: 1.8vh 1vw!important;
}
<style>
5.2 初始化footer栏当前日期
- 具体实现:
- 在当前组件的
<sricpt setup></script>
中实现:
- 在当前组件的
/*======================初始化UI===========================*/
let currentDate = new Date();
let week = ["日", "一", "二", " 三", "四", "五", "六"];
let currentDataShow = ref(`${currentDate.getFullYear()}年${currentDate.getMonth() + 1}月${currentDate.getDate()}日 星期${week[currentDate.getDay()]}`);
5.3 创建系统websocket连接
5.3.1 定义websocket连接工具
- 具体实现
- 在plugins/websocket.ts中实现
import {notifyBox} from "@/plugins/api";
import router from "@/router";
export default {
initWs(url:string)
{
if(!WebSocket)
{
notifyBox("您的浏览器不支持本系统,请更换为chrome或edge", "error");
return;
}
let ws = new WebSocket(url);
ws.onopen = this.onConnectServer;
ws.onerror = this.onConnectError;
ws.onclose = this.onConnectClose;
return ws;
},
onConnectServer()
{
notifyBox("系统初始化成功!", "success");
},
onConnectError()
{
router.push("/login").then(r => {notifyBox("系统初始化失败,请重新登录!", "error");});
},
onConnectClose()
{
notifyBox("您与服务器已断开,将无法接收服务器推送信息!", "info");
},
}
5.3.2 初始化websocket连接
- 具体实现
- 在当前组件的
<sricpt setup></script>
中实现:
- 在当前组件的
/*======================变量声明区==========================*/
// 保存服务器端推送消息
let msgArr = ref<any>([]);
// 将保存服务器端推送消息提供给后代组件使用
provide("msgArr", msgArr);
/*======================webSocket的baseURL=================*/
const baseUrl = 'localhost:9040'
/*=====================初始化websocket====================*/
// @ts-ignore
const currentUserId = JSON.parse(sessionStorage.getItem(systemConst.currentUser)).id;
let wsCnnt = ws.initWs(`ws://${baseUrl}/xfsyws/${currentUserId}`);
function receiveMessage(message:any)
{
console.log(message);
//msgBox(message);
}
if(wsCnnt)
wsCnnt.onmessage = receiveMessage;
/*=================其它生命周期及组合API==================*/
onBeforeUnmount(()=>{
xfsyWebsocketCloseConnect();
})
/*================其它方法==============================*/
function xfsyWebsocketCloseConnect()
{
wsCnnt?.close();
}
window.addEventListener("beforeunload", e =>{
xfsyWebsocketCloseConnect();
})
6 服务端新增模块
服务端新增文件与消息中心微服务模块,该模块用于处理文件上传,消息队列中消息消费,保存用户操作日志到mongodb中。该模块为服务消费者模块的子模块。
6.1 创建模块
创建xfsy-file-msg-center
模块,创建后服务端项目结构如图所示
6.2 添加依赖
6.2.1 xfsy-consumer更新pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>xfsy-server</artifactId>
<groupId>org.wjk</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>xfsy-consumer</artifactId>
<packaging>pom</packaging>
<modules>
<module>xfsy-service-manager</module>
<module>xfsy-file-msg-center</module>
</modules>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.wjk</groupId>
<artifactId>xfsy-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--添加消息队列依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rocketmq</artifactId>
<version>2021.0.4.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-acl</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.9.2</version>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-acl</artifactId>
<version>4.9.2</version>
</dependency>
</dependencies>
</project>
6.2.2 xfsy-file-msg-center的pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>xfsy-consumer</artifactId>
<groupId>org.wjk</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>xfsy-file-msg-center</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.wjk</groupId>
<artifactId>xfsy-consumer</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--整合websocket-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!--整合mongoDB-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
</dependencies>
</project>
6.3 配置模块
- bootstrap.yml实现
server:
port: 9040
spring:
application:
name: xfsy-file-msg-center
cloud:
nacos:
config:
server-addr: ************:8848
file-extension: yml
group: dev
namespace: 2934dee2-2954-4cb4-93db-0c0c8643f4b9
discovery:
server-addr: ************:8848
namespace: 2934dee2-2954-4cb4-93db-0c0c8643f4b9
group: dev
main:
banner-mode: off
- nacos上xfsy-file-msg-center.yml实现
spring:
cloud:
stream:
rocketmq:
binder:
name-server: ************************:9876
bindings:
systemMsg-in-0:
destination: xfsy_sytem_topic
data:
mongodb:
database: xfsy_v2
host: ****************
port: 27017
username: root
password: 138496wr
authentication-database: admin
# 配置本地目录为项目静态资源目录:注意该目录下所有命名不能出现非英文字符
web:
resources:
static-locations: file:D:/ulrica/Pictures/xfsy/
jedis:
max-idle: 33
max-total: 33
min-idle: 33
host: ***************
timeout: 10000
password: *****************
thread:
core-size: 33
max-size: 33
keep-alive: 60
queue-capacity: 256
name-prefix: xfsy_svcs_mngr_
logging:
level:
org.wjk: debug
system:
token:
signer-key: xfsy-systems
resource-id: xfsy-file-msg-center
# 无需认证即可访问的URL
permit-url: /xfsyws/**,/filestore/**
6.4 初始化websocket服务端
6.4.1 配置websocket
- 创建websocket配置类,将服务终端对象注入到IOC容器,具体实现如下:
package org.wjk.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig
{
/*该对象会自动注册使用@ServerEndpoint注解描述的类为一个websocket endpoint*/
@Bean
public ServerEndpointExporter serverEndpointExporter()
{
return new ServerEndpointExporter();
}
}
6.4.2 定义websocket终端
- 具体实现
package org.wjk.config;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.wjk.entity.vo.WsMsgVo;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Component
@ServerEndpoint("/xfsyws/{userId}")
@Slf4j
public class WebSocketHandler
{
private static final ConcurrentMap<Session, Integer> SESSION_ID_MAP = new ConcurrentHashMap<>();
private static final ConcurrentMap<Integer, Session> ID_SESSION_MAP = new ConcurrentHashMap<>();
@OnOpen // 有客户端连接到该终端时的回调函数,@PathParam注解等同于http协议使用的@PathVariable注解
public void onOpen(Session session, @PathParam("userId") Integer userId)
{
log.debug("用户 {} 连接到服务器!", userId);
SESSION_ID_MAP.put(session, userId);
ID_SESSION_MAP.put(userId, session);
}
@OnMessage // 接收到客户端推送消息时的回调
public void onMessage(Session session, String message)
{
Integer id = SESSION_ID_MAP.get(session);
log.debug("用户 {} 发送消息到服务器,消息主体为:{}", id, message);
}
@OnClose // 客户端断开连接时的回调
public void onClose(Session session)
{
Integer id = SESSION_ID_MAP.get(session);
try
{
SESSION_ID_MAP.remove(session);
ID_SESSION_MAP.remove(id);
log.debug("用户 {} 断开连接!", id);
session.close();
} catch (IOException e)
{
log.debug("客户端断开连接时,抛出IO异常,具体信息:{}", e.getMessage());
e.printStackTrace();
}
}
// 服务端向所有联接的客户端推送消息
public static void sendMsgToAllUser(String msg)
{
for (Session session : SESSION_ID_MAP.keySet())
{
try
{
session.getBasicRemote().sendText(msg);
} catch (IOException e)
{
log.debug("推送消息给全体在线用户时,抛出IO异常,具体信息为:{}", e.getMessage());
e.printStackTrace();
}
}
}
// 服务端向指定客户端推送消息
public static void sendMsgToSpecificUserById(Integer id, String msg)
{
Session session = ID_SESSION_MAP.get(id);
try
{
session.getBasicRemote().sendText(msg);
} catch (IOException e)
{
log.debug("推送消息给全体在线用户时,抛出IO异常,具体信息为:{}", e.getMessage());
e.printStackTrace();
}
}
}
6.4 定义rocketmq消息消费者
package org.wjk.msghandler;
import com.alibaba.fastjson.JSON;
import org.springframework.context.annotation.Bean;
import org.springframework.messaging.Message;
import org.springframework.stereotype.Service;
import org.wjk.config.WebSocketHandler;
import org.wjk.entity.vo.WsMsgVo;
import org.wjk.utils.constant.MsgConst;
import java.util.Objects;
import java.util.function.Consumer;
@Service
public class MsgConsumers<T>
{
@Bean("systemMsg")
public Consumer<Message<T>> systemMsgConsumer()
{
return msg -> {
switch ((String) Objects.requireNonNull(msg.getHeaders().get(MsgConst.TAGS_KEY)))
{
case MsgConst.WS_MSG_TAG:
break;
case MsgConst.RECODE_LOG_TAG:
break;
}
};
}
}