效果实现图
文件结构布局
在这张图中,我们把页面分为了三个部分,分别是:
- 左侧的
Menu菜单 - 顶部的
NavBar - 中间的内容区
Main
本章中我们将会实现以下的核心解决方案:
- 用户退出方案
- 动态侧边栏方案
- 动态面包屑方案
除了这些核心内容之外,还有一些其他的小功能,比如:
- 退出的通用逻辑封装
- 伸缩侧边栏动画
vue3动画- 组件状态驱动的动态
CSS值等等
换句话而言,掌握了本章中的内容之后,后台项目的通用 Layout 处理,对于来说将变得小菜一碟!
1. 整个页面分为三部分,所以我们需要先去创建对应的三个组件:
layout/components/Sidebar/index.vuelayout/components/Navbar.vuelayout/components/AppMain.vue
2. 然后在 layout/index.vue 中引入这三个组件
<script setup>
import Navbar from './components/Navbar'
import Sidebar from './components/Sidebar'
import AppMain from './components/AppMain'
</script>
3. 完成对应的布局结构
<template>
<div class="app-wrapper">
<!-- 左侧 menu -->
<sidebar
id="guide-sidebar"
class="sidebar-container"
/>
<div class="main-container">
<div class="fixed-header">
<!-- 顶部的 navbar -->
<navbar />
</div>
<!-- 内容区 -->
<app-main />
</div>
</div>
</template>
4. 在 styles 中创建如下 css 文件:
variables.scss: 定义常量mixin.scss:定义通用的csssidebar.scss:处理menu菜单的样式
5. 为 variables.scss ,定义如下常量并进行导出( :export 可见 scss 与 js 共享变量):
// sidebar
$menuText: #bfcbd9;
$menuActiveText: #ffffff;
$subMenuActiveText: #f4f4f5;
$menuBg: #304156;
$menuHover: #263445;
$subMenuBg: #1f2d3d;
$subMenuHover: #001528;
$sideBarWidth: 210px;
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
}
6. 为 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%;
}
7. 为 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;
}
}
}
10. 因为将来要实现 主题更换,所以为 sidebar 赋值动态的背景颜色
```vue
<template>
...
<!-- 左侧 menu -->
<sidebar
class="sidebar-container"
:style="{ backgroundColor: variables.menuBg }"
/>
...
</template>
<script setup>
import variables from '@/styles/variables.scss'
</script>
```
11. 为 Navbar、Sidebar、AppMain 组件进行初始化代码
```vue
<template>
<div class="">{组件名}</div>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped></style>
```
获取用户基本信息
接下来我们实现一下 navbar 中的 头像菜单 功能
这样的一个功能主要分为三个部分:
- 获取并展示用户信息
element-plus中的dropdown组件使用- 退出登录的方案实现
那么接下来我们就去实现第一部分的功能 获取并展示用户信息
获取并展示用户信息 我们把它分为三部分进行实现:
- 定义接口请求方法
- 定义调用接口的动作
- 在权限拦截时触发动作
那么接下来我们就根据这三个步骤,分别来进行实现:
1. 定义接口请求方法
在 src/api/sys.js 中定义了 getUserInfo 方法。
/**
* 获取用户信息
*/
export const getUserInfo = () => {
return request({
url: '/sys/profile',
})
}
2. 在请求拦截器中注入 token
在 src/utils/request.js 中: 导入 useAuthStore 在请求拦截器中检查 token 并注入到 Authorization 请求头 格式为 Bearer ${token}
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API, // 注意这里的更改
timeout: 5000,
})
// 请求拦截器
service.interceptors.request.use(
config => {
config.headers.icode = `helloqianduanxunlianying`
// 在这个位置需要统一的去注入token
const authStore = useAuthStore()
if (authStore.token) {
// 如果token存在 注入token
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config // 必须返回配置
},
error => {
return Promise.reject(error)
},
)
// 响应拦截器
service.interceptors.response.use(
response => {
const { success, message, data } = response.data
// 要根据success的成功与否决定下面的操作
if (success) {
return data
} else {
// 业务错误
ElMessage.error(message) // 提示错误消息
return Promise.reject(new Error(message))
}
},
error => {
// TODO: 将来处理 token 超时问题
ElMessage.error(error.message) // 提示错误信息
return Promise.reject(error)
},
)
export default service
3. 定义调用接口的动作
在 src/stores/auth.js 中: 将 user 改为 userInfo,初始化为空对象 {} 添加 getUserInfo action 用于获取用户信息 添加 hasUserInfo getter 判断是否已存在用户信息 在 state 初始化时从 localStorage 读取 token,确保刷新页面后 token 仍然可用
import { defineStore } from 'pinia'
import { login, getUserInfo } from '@/api/sys'
export const useAuthStore = defineStore('auth', {
state: () => {
// 从 localStorage 初始化 token
const token = localStorage.getItem('token')
return {
token: token || null,
userInfo: {},
}
},
actions: {
async login(username, password) {
try {
const response = await login({ username, password })
this.token = response.token
localStorage.setItem('token', this.token)
return true
} catch (error) {
console.error('Login failed:', error)
return false
}
},
async getUserInfo() {
const res = await getUserInfo()
this.userInfo = res
return res
},
logout() {
this.token = null
this.userInfo = {}
localStorage.removeItem('token')
},
},
getters: {
isLoggedIn: state => !!state.token,
/**
* @returns true 表示已存在用户信息
*/
hasUserInfo: state => {
return JSON.stringify(state.userInfo) !== '{}'
},
},
})
4. 在权限拦截时触发动作
在 src/router/index.js 中: 将路由守卫改为 async 函数 在非登录页面且用户已登录但未获取用户信息时,自动调用 getUserInfo action 确保在进入页面前获取用户信息
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/login',
name: 'login',
// route level code-splitting
// this generates a separate chunk (login.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/LoginView.vue'),
},
],
})
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
if (to.name === 'login' && authStore.isLoggedIn) {
// 如果用户已经登录,尝试访问登录页面,则重定向到首页
next({ name: 'home' })
} else if (to.name !== 'login' && !authStore.isLoggedIn) {
// 如果用户未登录,尝试访问非登录页面,则重定向到登录页面
next({ name: 'login' })
} else {
// 判断用户资料是否获取
// 若不存在用户信息,则需要获取用户信息
if (to.name !== 'login' && !authStore.hasUserInfo) {
// 触发获取用户信息的 action
await authStore.getUserInfo()
}
next()
}
})
export default router
在登录之后,接口请求就成功了。
渲染用户头像菜单
到现在我们已经拿到了用户数据 ,那么接下来我们就可以根据数据渲染出 用户头像内容,实现 Navbar 组件的用户头像展示功能。实现内容如下:
1. 使用 Pinia Store 获取用户信息
- 使用
useAuthStore()获取用户信息(非 Vuex 的$store.getters) - 使用可选链
?.安全访问avatar属性
2. 实现头像展示
- 使用 Element Plus 的
el-avatar组件 - 设置
shape="square"和size="40" - 绑定用户头像 URL:
authStore.userInfo?.avatar
3. 实现下拉菜单
- 使用 Element Plus 的
el-dropdown组件 - 设置
trigger="click"点击触发 - 添加设置图标(使用 Element Plus 的
Setting图标组件)
4. 实现菜单项
- 首页:使用
router-link跳转到首页 - 课程主页:外部链接(可后续配置)
- 退出登录:点击后调用
handleLogout函数
5. 实现退出登录功能
- 调用
authStore.logout()清除 token 和用户信息 - 使用
router.push('/login')跳转到登录页
6. 样式实现
- 按照提供的样式规范实现
- 使用
:deep()深度选择器(Vue 3 语法,替代::v-deep) - 添加了 flex 布局和图标样式
<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="authStore.userInfo?.avatar"
></el-avatar>
<el-icon class="setting-icon"><Setting /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> 首页 </el-dropdown-item>
</router-link>
<a target="_blank" href="">
<el-dropdown-item>课程主页</el-dropdown-item>
</a>
<el-dropdown-item divided @click="handleLogout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { Setting } from '@element-plus/icons-vue'
const authStore = useAuthStore()
const router = useRouter()
function handleLogout() {
authStore.logout()
router.push('/login')
}
</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;
:deep(.avatar-container) {
cursor: pointer;
.avatar-wrapper {
margin-top: 5px;
position: relative;
display: flex;
align-items: center;
.el-avatar {
--el-avatar-background-color: none;
margin-right: 12px;
}
.setting-icon {
font-size: 16px;
color: #5a5e66;
}
}
}
}
}
</style>
退出登录方案实现
退出登录 一直是一个通用的前端实现方案,对于退出登录而言,它的触发时机一般有两种:
- 用户主动退出
- 用户被动退出
其中:
- 主动退出指:用户点击登录按钮之后退出
- 被动退出指:
token过期或被 其他人”顶下来“ 时退出
那么无论是什么退出方式,在用户退出时,所需要执行的操作都是固定的:
- 清理掉当前用户缓存数据
- 清理掉权限相关配置
- 返回到登录页
那么明确好了对应的方案之后,接下来咱们就先来实现 用户主动退出的对应策略
1.主动退出登录功能。实现总结如下:
1. 创建 localStorage 工具函数 ✅
在 src/utils/storage.js 中创建了工具函数:
removeAllItem(): 清理所有 localStorage 数据getItem(): 获取 localStorage 项(支持 JSON 解析)setItem(): 设置 localStorage 项(支持对象)removeItem(): 移除指定 localStorage 项
2. 完善 logout action ✅
在 src/stores/auth.js 中完善了 logout action:
- 清理 token:
this.token = null - 清理用户信息:
this.userInfo = {} - 清理所有缓存:调用
removeAllItem()清除所有 localStorage 数据
3. Navbar 组件中的退出登录 ✅
在 src/layout/components/Navbar.vue 中:
handleLogout函数已正确实现- 调用
authStore.logout()清理数据 - 使用
router.push('/login')跳转到登录页
功能特点
- 数据清理:退出时清理 token、用户信息和所有 localStorage 缓存
- 路由跳转:自动跳转到登录页
- 代码规范:使用 Pinia(非 Vuex),符合项目架构
退出登录流程
- 用户点击“退出登录”按钮
- 触发
handleLogout函数 - 调用
authStore.logout()清理所有数据 - 跳转到
/login登录页
代码已通过 linter 检查,可直接使用。用户点击退出登录后,会清理所有缓存数据并跳转到登录页。
用户被动退出 的场景主要有两个:
token失效- 单用户登录:其他人登录该账号被 “顶下来”
那么这两种场景下,在前端对应的处理方案一共也分为两种,共分为 主动处理 、被动处理 两种 :
- 主动处理:主要应对
token失效 - 被动处理:同时应对
token失效 与 单用户登录
那么这两种方案基本上就覆盖了用户被动退出时的主要业务场景了
用户被动退出解决方案之主动处理
1.想要搞明白 主动处理 方案,那么首先我们得先去搞明白对应的 背景 以及 业务逻辑 。
那么首先我们先明确一下对应的 背景:
我们知道
token表示了一个用户的身份令牌,对 服务端 而言,它是只认令牌不认人的。所以说一旦其他人获取到了你的token,那么就可以伪装成你,来获取对应的敏感数据。所以为了保证用户的信息安全,那么对于
token而言就被制定了很多的安全策略,比如:
- 动态
token(可变token)- 刷新
token- 时效
token- ...
这些方案各有利弊,没有绝对的完美的策略。
而我们此时所选择的方案就是 时效 token
对于 token 本身是拥有时效的,这个大家都知道。但是通常情况下,这个时效都是在服务端进行处理。而此时我们要在 服务端处理 token 时效的同时,在前端主动介入 token 时效的处理中。 从而保证用户信息的更加安全性。
那么对应到我们代码中的实现方案为:
- 在用户登陆时,记录当前 登录时间
- 制定一个 失效时长
- 在接口调用时,根据 当前时间 对比 登录时间 ,看是否超过了 时效时长
- 如果未超过,则正常进行后续操作
- 如果超过,则进行 退出登录 操作
用户被动退出解决方案之被动处理
我们处理了 用户被动退出时的主动处理 ,那么在这我们去处理 用户被动退出时的被动处理 。
背景:
首先我们需要先明确 被动处理 需要应对两种业务场景:
token过期- 单用户登录
然后我们一个一个来去看,首先是 token 过期
我们知道对于
token而言,本身就是具备时效的,这个是在服务端生成token时就已经确定的。而此时我们所谓的
token过期指的就是:服务端生成的
token超过 服务端指定时效 的过程
而对于 单用户登录 而言,指的是:
当用户 A 登录之后,
token过期之前。用户 A 的账号在其他的设备中进行了二次登录,导致第一次登录的 A 账号被 “顶下来” 的过程。
即:同一账户仅可以在一个设备中保持在线状态
那么明确好了对应的背景之后,接下来我们来看对应的业务处理场景:
从背景中我们知道,以上的两种情况,都是在 服务端进行判断的,而对于前端而言其实是 服务端通知前端的一个过程。
所以说对于其业务处理,将遵循以下逻辑:
- 服务端返回数据时,会通过特定的状态码通知前端
- 当前端接收到特定状态码时,表示遇到了特定状态:
token时效 或 单用户登录 - 此时进行 退出登录 处理