包括后台项目 layout 基础架构、用户主动和被动退出、动态 menu 菜单、结构路由表、侧边菜单栏的收缩、组件状态驱动的动态 CSS 值

layout 基础架构布局

layout 基本布局结构包括:Menu 菜单栏、NavBar 顶部导航栏(包含个人信息)、Main(页面主要展示区域)

1、登录成功进入首页之后,整个首页也就是上面说的分为三个部分

创建三个对应的组件

layout/components/Sidebar/index.vue
layout/components/Navbar.vue
layout/components/AppMain.vue

2、在layout/index引入创建的三个组件

1
2
3
import Navbar from "./components/Navbar.vue";
import Sidebar from "./components/Sidebar/index.vue";
import AppMain from "./components/AppMain";

3、layout/index对应的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div class="app-wrapper">
<!-- 左侧 menu -->
<sidebar class="sidebar-container" />
<div class="main-container">
<div class="fixed-header">
<!-- 顶部的 navbar -->
<navbar />
</div>
<!-- 内容区 -->
<app-main />
</div>
</div>
</template>

4、在 styles 文件夹下定义 3 个 scss 文件

variables.scss 定义常量
mixin.scss 定义通用的 css
sidebar.scss 处理 menu 菜单的样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// variables.scss
// sidebar的样式 定义如下常量将其导出
$menuText: #bfcbd9;
$menuActiveText: #ffffff;
$subMenuActiveText: #f4f4f5;

$menuBg: #304156;
$menuHover: #263445;

$subMenuBg: #1f2d3d;
$subMenuHover: #001528;

$sideBarWidth: 210px;

// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
}

注意:这里是使用 :export{ } 方法将 css 常量导出

mixin.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// mixin.scss
@mixin clearfix {
&:after {
content: "";
display: table;
clear: both;
}
}

@mixin scrollBar {
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}

&::-webkit-scrollbar {
width: 6px;
}

&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}

@mixin relative {
position: relative;
width: 100%;
height: 100%;
}

sidebar.scss

点我展示代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// sidebar.scss
#app {
.main-container {
min-height: 100%;
transition: margin-left 0.28s;
margin-left: $sideBarWidth;
position: relative;
}

.sidebar-container {
transition: width 0.28s;
width: $sideBarWidth !important;
height: 100%;
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;

// 重置 element-plus 的css
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out,
0s padding-right ease-in-out;
}

.scrollbar-wrapper {
overflow-x: hidden !important;
}

.el-scrollbar__bar.is-vertical {
right: 0px;
}

.el-scrollbar {
height: 100%;
}

&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
}
}

.is-horizontal {
display: none;
}

a {
display: inline-block;
width: 100%;
overflow: hidden;
}

.svg-icon {
margin-right: 16px;
}

.sub-el-icon {
margin-right: 12px;
margin-left: -2px;
}

.el-menu {
border: none;
height: 100%;
width: 100% !important;
}

.is-active > .el-submenu__title {
color: $subMenuActiveText !important;
}

& .nest-menu .el-submenu > .el-submenu__title,
& .el-submenu .el-menu-item {
min-width: $sideBarWidth !important;
}
}

.hideSidebar {
.sidebar-container {
width: 54px !important;
}

.main-container {
margin-left: 54px;
}

.submenu-title-noDropdown {
padding: 0 !important;
position: relative;

.el-tooltip {
padding: 0 !important;

.svg-icon {
margin-left: 20px;
}

.sub-el-icon {
margin-left: 19px;
}
}
}

.el-submenu {
overflow: hidden;

& > .el-submenu__title {
padding: 0 !important;

.svg-icon {
margin-left: 20px;
}

.sub-el-icon {
margin-left: 19px;
}

.el-submenu__icon-arrow {
display: none;
}
}
}

.el-menu--collapse {
.el-submenu {
& > .el-submenu__title {
& > span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}

.el-menu--collapse .el-menu .el-submenu {
min-width: $sideBarWidth !important;
}

.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
}

.el-menu--vertical {
& > .el-menu {
.svg-icon {
margin-right: 16px;
}
.sub-el-icon {
margin-right: 12px;
margin-left: -2px;
}
}

// 菜单项过长时

> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;

&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}

&::-webkit-scrollbar {
width: 6px;
}

&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}

5、创建的三个 scss 文件导入到 styles/index.scss

1
2
3
4
// index.scss
@import "./variables.scss";
@import "./mixin.scss";
@import "./sidebar.scss";

layout/index.vue写入样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<style lang="scss" scoped>
@import '@/styles/mixin.scss';
@import '@/styles/variables.scss';

.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
}

.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
}
</style>

6、侧边栏要实现主题更换功能,sidebar 赋值动态背景颜色

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
...
<!-- 左侧 menu -->
<sidebar
class="sidebar-container"
:style="{ backgroundColor: variables.menuBg }"
/>
...
</template>

<script setup>
import variables from "@/styles/variables.scss";
</script>

7、Navbar、Sidebar、AppMain 组件初始化代码占位及样式修改

需要处理 AppMain 组件和 Navbar 组件重叠问题

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div class="app-main">AppMain</div>
</template>
<style lang="scss" scoped>
.app-main {
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow: hidden;
padding: 61px 20px 20px 20px;
box-sizing: border-box;
}
</style>

8、用户个人信息

这里需要处理一下用户个人信息展示

api/sys.js文件定义方法、axios 请求拦截器对 token 进行 header 注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 获取用户信息
*/
export const getUserInfo = () => {
return request({
url: "/sys/profile",
});
};

// utils/request.js
import store from "@/store";
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 在这个位置需要统一的去注入token
if (store.getters.token) {
// 如果token存在 注入token
config.headers.Authorization = `Bearer ${store.getters.token}`;
}
return config; // 必须返回配置
},
(error) => {
return Promise.reject(error);
}
);

定义调用接口的动作store/modules/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { login, getUserInfo } from '@/api/sys'
...
export default {
namespaced: true,
state: () => ({
...
userInfo: {}
}),
mutations: {
...
setUserInfo(state, userInfo) {
state.userInfo = userInfo
}
},
actions: {
...
async getUserInfo(context) {
const res = await getUserInfo()
this.commit('user/setUserInfo', res)
return res
}
}
}

在权限拦截时触发的动作permission.js

1
2
3
4
5
6
7
8
9
10
11
12
  if (to.path === '/login') {
...
} else {
// 判断用户资料是否获取
// 若不存在用户信息,则需要获取用户信息
if (!store.getters.hasUserInfo) {
// 触发获取用户信息的 action
await store.dispatch('user/getUserInfo')
}
next()
}
}

写入判断用户信息store/getters.js

1
2
3
4
5
6
7
8
9
10
const getters = {
...
userInfo: state => state.user.userInfo,
/**
* @returns true 表示已存在用户信息
*/
hasUserInfo: state => {
return JSON.stringify(state.user.userInfo) !== '{}'
}
}

9、element-plus 的 dropdown avatar 组件实现用户头像信息及用户主动退出功能

用户头像功能及下拉菜单栏

点我展示代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// layout/components/navbar.vue
<template>
<div class="navbar">
<div class="right-menu">
<!-- 头像 -->
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<el-avatar
shape="square"
:size="40"
:src="$store.getters.userInfo.avatar"
></el-avatar>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> 首页 </el-dropdown-item>
</router-link>
<el-dropdown-item divided> 退出登录 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>

<script setup>
import {} from "vue";
</script>

<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);

.right-menu {
display: flex;
align-items: center;
float: right;
padding-right: 16px;

::v-deep .avatar-container {
cursor: pointer;
.avatar-wrapper {
margin-top: 5px;
position: relative;
n .el-avatar {
--el-avatar-background-color: none;
margin-right: 12px;
}
}
}
}
}
</style>

10、主动退出功能:在 store/moduels/user.js 中,清除当前用户缓存数据、清除用户权限相关配置、返回到登录页

1
2
3
4
5
6
7
8
import router from '@/router'

logout() {
this.commit('user/setToken', '')
this.commit('user/setUserInfo', {})
removeAllItem()
router.push('/login')
}

退出按钮点击事件,触发 logout 的 action

1
2
3
4
5
6
import { useStore } from "vuex";

const store = useStore();
const logout = () => {
store.dispatch("user/logout");
};

11、被动退出功能:token 过期、单一账户同时登录

token 表示一个用户的身份令牌,对服务端而言,只认令牌不认人,所以说一旦其他人获取了你的 token,那么可以伪装成你,来获取对应的敏感数据。

为了保证用户的信息安全,那么对于 token 而言就被制定了很多安全策略。
token 的安全策略:动态 token、刷新 token、失效 token

选择时效 token:服务端处理 token 时效的同时,在前端主动介入 token 时效的处理中,从而保证用户信息的安全性。

实现方案:

1、在用户登录时,记录当前登录时间
2、制定失效时长
3、接口调用时,根据当前时间对比登录时间,看是否超过了时效时长。如果未超过,则正常进行后续操作;如果超过,则进行退出登录操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// utils/auth.js
import { TIME_STAMP, TOKEN_TIMEOUT_VALUE } from "@/constant";
import { setItem, getItem } from "@/utils/storage";
/**
* 获取时间戳
*/
export function getTimeStamp() {
return getItem(TIME_STAMP);
}
/**
* 设置时间戳
*/
export function setTimeStamp() {
setItem(TIME_STAMP, Date.now());
}
/**
* 是否超时
*/
export function isCheckTimeout() {
// 当前时间戳
var currentTime = Date.now();
// 缓存时间戳
var timeStamp = getTimeStamp();
return currentTime - timeStamp > TOKEN_TIMEOUT_VALUE;
}

// token时间戳
export const TIME_STAMP = "timeStamp";
// 超时时长(毫秒) 两小时
export const TOKEN_TIMEOUT_VALUE = 2 * 3600 * 1000;

用户登录成功之后设置时间,到 store/user.js 的 login 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { setTimeStamp } from '@/utils/auth'

login(context, userInfo) {
...
return new Promise((resolve, reject) => {
...
.then(data => {
...
// 保存登录时间
setTimeStamp()
resolve()
})
})
},

utils/request对应的请求拦截器中进行主动介入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { isCheckTimeout } from '@/utils/auth'

if (store.getters.token) {
if (isCheckTimeout()) {
// 登出操作
store.dispatch('user/logout')
return Promise.reject(new Error('token 失效'))
}
...
}

// 响应拦截器
service.interceptors.response.use(
response => {
...
},
error => {
// 处理 token 超时问题
if (
error.response &&
error.response.data &&
error.response.data.code === 401
) {
// token超时
store.dispatch('user/logout')
}
ElMessage.error(error.message) // 提示错误信息
return Promise.reject(error)
}
)

12、临时menu菜单

layout/Sidebar/SidebarMenu.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<!-- 一级 menu 菜单 -->
<el-menu
:uniqueOpened="true"
default-active="2"
background-color="#545c64"
text-color="#fff"
active-text-color="#ffd04b"
>
<!-- 子集 menu 菜单 -->
<el-submenu index="1">
<template #title>
<i class="el-icon-location"></i>
<span>导航一</span>
</template>
<el-menu-item index="1-1">选项1</el-menu-item>
<el-menu-item index="1-2">选项2</el-menu-item>
</el-submenu>
<!-- 具体菜单项 -->
<el-menu-item index="4">
<i class="el-icon-setting"></i>
<template #title>导航四</template>
</el-menu-item>
</el-menu>
</template>

layout/Sidebar/index 导入该组件

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div class="">
<h1>Admin</h1>
<el-scrollbar>
<sidebar-menu></sidebar-menu>
</el-scrollbar>
</div>
</template>

<script setup>
import SidebarMenu from './SidebarMenu'
import {} from 'vue'
</script>

生成一个临时的 menu 菜单,从这个临时的 menu 菜单可以看出,el-menu 分为三部分:

el-menu:整个 menu 菜单
el-sub-menu:子集 menu 菜单
el-menu-item:具体菜单项

13、动态 menu 菜单处理

动态 menu 菜单主要和动态路由表配合来实现用户权限的(后续需要完成用户权限)。动态 menu 菜单指的是:根据路由表的配置,自动生成对应的 menu 菜单,当路由表发生变化时,menu 菜单自动发生变化。

实现步骤:

1、定义路由表对应的 menu 菜单规则(核心步骤)
2、根据规则制定路由表
3、根据规则,一路路由表生成 menu 菜单

上面步骤 1 的菜单规则制定:

1、如果meta && meta.title && meta.icon :则显示在 menu 菜单中,其中 title 为显示的内容,icon 为显示的图标。如果存在 children:则展示子菜单 el-sub-menu;否则展示 el-menu-item。
2、不显示在 menu 中

14、创建页面组件

views 文件夹下创建页面:
(1)、创建文章:article-create
(2)、文章详情:article-detail
(3)、文章列表:article-ranking
(4)、401 和 404 页面:error-page/401.vue 404.vue
(5)、导入:import
(6)、权限列表:permission-list
(7)、个人中心:profile
(8)、角色列表:role-list
(9)、用户信息:user-info
(10)、用户管理:user-manage

15、用户权限处理

需要先对路由表进行划分:私有路由表 privateRoutes(权限路由)、公有路由表 publicRoutes(非权限路由)

router/index.js

显示详细代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
// 私有路由表
const privateRoutes = [
{
path: "/user",
component: layout,
redirect: "/user/manage",
meta: {
title: "user",
icon: "personnel",
},
children: [
{
path: "/user/manage",
component: () => import("@/views/user-manage/index"),
meta: {
title: "userManage",
icon: "personnel-manage",
},
},
{
path: "/user/role",
component: () => import("@/views/role-list/index"),
meta: {
title: "roleList",
icon: "role",
},
},
{
path: "/user/permission",
component: () => import("@/views/permission-list/index"),
meta: {
title: "permissionList",
icon: "permission",
},
},
{
path: "/user/info/:id",
name: "userInfo",
component: () => import("@/views/user-info/index"),
meta: {
title: "userInfo",
},
},
{
path: "/user/import",
name: "import",
component: () => import("@/views/import/index"),
meta: {
title: "excelImport",
},
},
],
},
{
path: "/article",
component: layout,
redirect: "/article/ranking",
meta: {
title: "article",
icon: "article",
},
children: [
{
path: "/article/ranking",
component: () => import("@/views/article-ranking/index"),
meta: {
title: "articleRanking",
icon: "article-ranking",
},
},
{
path: "/article/:id",
component: () => import("@/views/article-detail/index"),
meta: {
title: "articleDetail",
},
},
{
path: "/article/create",
component: () => import("@/views/article-create/index"),
meta: {
title: "articleCreate",
icon: "article-create",
},
},
{
path: "/article/editor/:id",
component: () => import("@/views/article-create/index"),
meta: {
title: "articleEditor",
},
},
],
},
];
// 公开路由表
const publicRoutes = [
{
path: "/login",
component: () => import("@/views/login/index"),
},
{
path: "/",
// 注意:带有路径“/”的记录中的组件“默认”是一个不返回 Promise 的函数
component: layout,
redirect: "/profile",
children: [
{
path: "/profile",
name: "profile",
component: () => import("@/views/profile/index"),
meta: {
title: "profile",
icon: "el-icon-user",
},
},
{
path: "/404",
name: "404",
component: () => import("@/views/error-page/404"),
},
{
path: "/401",
name: "401",
component: () => import("@/views/error-page/401"),
},
],
},
];

const router = createRouter({
history: createWebHashHistory(),
routes: [...publicRoutes, ...privateRoutes],
});

注意:要在 layout/AppMain.vue 中设置路由出口

1
2
3
4
5
<template>
<div class="app-main">
<router-view></router-view>
</div>
</template>

16、解析路由表,获取结构化数据

数据结构为

显示详细代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
[
{
"title": "个人中心",
"path": ""
},
{
"title": "用户",
"children": [
{
"title": "员工管理",
"path": ""
},
{
"title": "角色列表",
"path": ""
},
{
"title": "权限列表",
"path": ""
}
]
},
{
"title": "文章",
"children": [
{
"title": "文章排名",
"path": ""
},
{
"title": "创建文章",
"path": ""
}
]
}
]

获取路由表数据的 2 中方式:

1、router.options.routes:初始路由列表
2、router.getRoutes():获取所有路由记录的完整列表(选择此种方法)

在 layout/components/Sidebar/SidebarMenu 中

1
2
3
4
<script setup>
import {useRouter} from 'vue-router' const router = useRouter()
console.log(router.getRoutes()) // 这里返回的是一个完整的路由表
</script>

打印的结果是一个完整的路由表,里面存在一些问题:

存在重复的路由数据
不满足该条件 meta && meta.title && meta.icon 的数据不应该存在

17、处理数据结构问题

创建utils/route.js,里面 2 个函数方法:filterRouters()/generateMenus()很重要

显示完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import path from "path";

/**
* 返回所有子路由
*/
const getChildrenRoutes = (routes) => {
const result = [];
routes.forEach((route) => {
if (route.children && route.children.length > 0) {
result.push(...route.children);
}
});
return result;
};
/**
* 处理脱离层级的路由:某个一级路由为其他子路由,则剔除该一级路由,保留路由层级
* @param {*} routes router.getRoutes()
*/
export const filterRouters = (routes) => {
const childrenRoutes = getChildrenRoutes(routes);
return routes.filter((route) => {
return !childrenRoutes.find((childrenRoute) => {
return childrenRoute.path === route.path;
});
});
};

/**
* 判断数据是否为空值
*/
function isNull(data) {
if (!data) return true;
if (JSON.stringify(data) === "{}") return true;
if (JSON.stringify(data) === "[]") return true;
return false;
}
/**
* 根据 routes 数据,返回对应 menu 规则数组
*/
export function generateMenus(routes, basePath = "") {
const result = [];
// 遍历路由表
routes.forEach((item) => {
// 不存在 children && 不存在 meta 直接 return
if (isNull(item.meta) && isNull(item.children)) return;
// 存在 children 不存在 meta,进入迭代
if (isNull(item.meta) && !isNull(item.children)) {
result.push(...generateMenus(item.children));
return;
}
// 合并 path 作为跳转路径
const routePath = path.resolve(basePath, item.path);
// 路由分离之后,存在同名父路由的情况,需要单独处理
let route = result.find((item) => item.path === routePath);
if (!route) {
route = {
...item,
path: routePath,
children: [],
};

// icon 与 title 必须全部存在
if (route.meta.icon && route.meta.title) {
// meta 存在生成 route 对象,放入 arr
result.push(route);
}
}

// 存在 children 进入迭代到children
if (item.children) {
route.children.push(...generateMenus(item.children, route.path));
}
});
return result;
}

18、SidebarMenu 调用 filterRouters、generateMenus 方法

1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { filterRouters, generateMenus } from '@/utils/route'

const router = useRouter()
const routes = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
return generateMenus(filterRoutes)
})
console.log(JSON.stringify(routes.value))
</script>

19、生成动态 menu 菜单

整个 menu 菜单,分为 3 个组件来进行处理

1、SidebarMenu:处理数据,作为最顶层 menu 载体

1
2
3
4
5
6
7
8
9
10
<template>
<!-- 一级 menu 菜单 -->
<el-menu ...>
<sidebar-item
v-for="item in routes"
:key="item.path"
:route="item"
></sidebar-item>
</el-menu>
</template>

2、SidebarItem:根据数据处理当前项为 el-sub-menu || el-menu-item

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<!-- 支持渲染多级 menu 菜单 -->
<el-submenu v-if="route.children.length > 0" :index="route.path">
<template #title>
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</template>
<!-- 循环渲染 -->
<sidebar-item
v-for="item in route.children"
:key="item.path"
:route="item"
></sidebar-item>
</el-submenu>
<!-- 渲染 item 项 -->
<el-menu-item v-else :index="route.path">
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</el-menu-item>
</template>

<script setup>
import MenuItem from "./MenuItem";
import { defineProps } from "vue";
// 定义 props
defineProps({
route: {
type: Object,
required: true,
},
});
</script>

3、MenuItem:处理 el-menu-item 样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<i v-if="icon.includes('el-icon')" class="sub-el-icon" :class="icon"></i>
<svg-icon v-else :icon="icon"></svg-icon>
<span>{{ title }}</span>
</template>

<script setup>
import { defineProps } from "vue";
defineProps({
title: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
});
</script>

<style lang="scss" scoped></style>

menu 菜单完成,还需解决样式问题、菜单路由跳转问题、默认激活项

样式修改:store/getters 创建快捷访问(后续加入主题更换功能,样式不能写死)

1
2
3
4
5
6
import variables from '@/styles/variables.scss'
const getters = {
...
cssVar: state => variables
}
export default getters

SidebarMenu写入样式(el-menu 里的 router 是指定路由跳转, activeMenu 为默认激活项)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// template
<el-menu
:background-color="$store.getters.cssVar.menuBg"
:text-color="$store.getters.cssVar.menuText"
:active-text-color="$store.getters.cssVar.menuActiveText"
:default-active="activeMenu"
:unique-opened="true"
router
></el-menu>

// js
<script setup>
...

// 计算高亮 menu 的方法
const route = useRoute()
const activeMenu = computed(() => {
const { path } = route
return path
})
</script>

20、菜单的伸缩功能

创建store/modules/app.js

1
2
3
4
5
6
7
8
9
10
11
12
export default {
namespaced: true,
state: () => ({
sidebarOpened: true,
}),
mutations: {
triggerSidebarOpened(state) {
state.sidebarOpened = !state.sidebarOpened;
},
},
actions: {},
};

store/index.js中引入上面的 app.js

1
2
3
4
5
6
7
8
import app from "./modules/app";
export default createStore({
getters,
modules: {
user,
app,
},
});

store/getters 中创建快捷访问

1
sidebarOpened: (state) => state.app.sidebarOpened;

21、创建收缩 icon 组件 hamburger

创建src/components/Hamburger/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
<div class="hamburger-container" @click="toggleClick">
<svg-icon class="hamburger" :icon="icon"></svg-icon>
</div>
</template>

<script setup>
import { computed } from "vue";
import { useStore } from "vuex";

const store = useStore();
const toggleClick = () => {
store.commit("app/triggerSidebarOpened");
};

const icon = computed(() =>
store.getters.sidebarOpened ? "hamburger-opened" : "hamburger-closed"
);
</script>

<style lang="scss" scoped>
.hamburger-container {
padding: 0 16px;
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
}
</style>

layout/components/Navbar.vue中使用 Hamburger 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
<div class="navbar">
<hamburger class="hamburger-container" />
...
</div>
</template>

<script setup>
import Hamburger from '@/components/Hamburger'
...
</script>

<style lang="scss" scoped>
.navbar {
...

.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
// hover 动画
transition: background 0.5s;

&:hover {
background: rgba(0, 0, 0, 0.1);
}
}

...
}
</style>

layout/components/Sidebar/SidebarMenu.vue中添加控制 el-menu 的collapse属性

1
2
3
<el-menu
:collapse="!$store.getters.sidebarOpened"
...

layout/index.vue中指定侧边栏的宽度和缩放动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<div
class="app-wrapper"
:class="[$store.getters.sidebarOpened ? 'openSidebar' : 'hideSidebar']"
>
...
</div>

<style lang="scss" scoped>
... .fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
transition: width 0.28s;
}

.hideSidebar .fixed-header {
width: calc(100% - #{$hideSideBarWidth});
}
</style>

<style>
// 在 `styles/variables.scss` 中指定 `hideSideBarWidth`
$hideSideBarWidth: 54px;
</style>

22、SidebarHeader 处理

显示详细代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
<div class="">
<div class="logo-container">
<el-avatar size="44" shape="square" src="http://xxxxx.com" />
<h1 class="logo-title" v-if="$store.getters.sidebarOpened">Admin</h1>
</div>
...
</div>
</template>

<style lang="scss" scoped>
.logo-container {
height: 44px;
padding: 10px 0 22px 0;
display: flex;
align-items: center;
justify-content: center;
.logo-title {
margin-left: 10px;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 16px;
white-space: nowrap;
}
}
</style>

处理 el-avatar 的图片背景问题

创建 styles/element.scss,然后将其导入到style/index.scss

1
2
3
.el-avatar {
--el-avatar-background-color: none;
}

处理动画问题

styles/variables.scss 中,加入以下变量

1
$sideBarDuration: 0.28s;

styles/sidebar.scss 修改时长

1
2
3
4
5
6
7
8
9
.main-container {
transition: margin-left #{$sideBarDuration};
...
}

.sidebar-container {
transition: width #{$sideBarDuration};
...
}

layout/index 修改样式

1
2
3
4
.fixed-header {
...
transition: width #{$sideBarDuration};
}

23、组件状态驱动的动态 CSS 值

1
2
3
4
5
6
7
8
9
10
11
12
13
<template> ... <el-avatar :size="logoHeight" ... </template>

<script setup>
...
const logoHeight = 44
</script>

<style lang="scss" scoped>
.logo-container {
height: v-bind(logoHeight) + 'px';
...
}
</style>