VUE后台管理系统:项目架构之搭建Layout架构解决方案与实现

144 阅读8分钟

效果实现图

image.png

文件结构布局

image.png

image.png 在这张图中,我们把页面分为了三个部分,分别是:

  1. 左侧的 Menu 菜单
  2. 顶部的 NavBar
  3. 中间的内容区 Main

本章中我们将会实现以下的核心解决方案:

  1. 用户退出方案
  2. 动态侧边栏方案
  3. 动态面包屑方案

除了这些核心内容之外,还有一些其他的小功能,比如:

  1. 退出的通用逻辑封装
  2. 伸缩侧边栏动画
  3. vue3 动画
  4. 组件状态驱动的动态 CSS 值等等

换句话而言,掌握了本章中的内容之后,后台项目的通用 Layout 处理,对于来说将变得小菜一碟!

1. 整个页面分为三部分,所以我们需要先去创建对应的三个组件:
  1. layout/components/Sidebar/index.vue
  2. layout/components/Navbar.vue
  3. layout/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 文件:
  1. variables.scss : 定义常量
  2. mixin.scss :定义通用的 css
  3. sidebar.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. 为 NavbarSidebarAppMain 组件进行初始化代码
```vue
<template>
  <div class="">{组件名}</div>
</template>

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

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

```

代码:github.com/mohaixiao/v…

获取用户基本信息

接下来我们实现一下 navbar 中的 头像菜单 功能

image.png

这样的一个功能主要分为三个部分:

  1. 获取并展示用户信息
  2. element-plus 中的 dropdown 组件使用
  3. 退出登录的方案实现

那么接下来我们就去实现第一部分的功能 获取并展示用户信息

获取并展示用户信息 我们把它分为三部分进行实现:

  1. 定义接口请求方法
  2. 定义调用接口的动作
  3. 在权限拦截时触发动作

那么接下来我们就根据这三个步骤,分别来进行实现:

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

在登录之后,接口请求就成功了。

image.png

渲染用户头像菜单

到现在我们已经拿到了用户数据 ,那么接下来我们就可以根据数据渲染出 用户头像内容,实现 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>

代码: github.com/mohaixiao/v…

退出登录方案实现

退出登录 一直是一个通用的前端实现方案,对于退出登录而言,它的触发时机一般有两种:

  1. 用户主动退出
  2. 用户被动退出

其中:

  1. 主动退出指:用户点击登录按钮之后退出
  2. 被动退出指:token 过期或被 其他人”顶下来“ 时退出

那么无论是什么退出方式,在用户退出时,所需要执行的操作都是固定的:

  1. 清理掉当前用户缓存数据
  2. 清理掉权限相关配置
  3. 返回到登录页

那么明确好了对应的方案之后,接下来咱们就先来实现 用户主动退出的对应策略

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),符合项目架构
退出登录流程
  1. 用户点击“退出登录”按钮
  2. 触发 handleLogout 函数
  3. 调用 authStore.logout() 清理所有数据
  4. 跳转到 /login 登录页

代码已通过 linter 检查,可直接使用。用户点击退出登录后,会清理所有缓存数据并跳转到登录页。

代码:github.com/mohaixiao/v…

用户被动退出 的场景主要有两个:

  1. token 失效
  2. 单用户登录:其他人登录该账号被 “顶下来”

那么这两种场景下,在前端对应的处理方案一共也分为两种,共分为 主动处理被动处理 两种 :

  1. 主动处理:主要应对 token 失效
  2. 被动处理:同时应对 token 失效 与 单用户登录

那么这两种方案基本上就覆盖了用户被动退出时的主要业务场景了

用户被动退出解决方案之主动处理

1.想要搞明白 主动处理 方案,那么首先我们得先去搞明白对应的 背景 以及 业务逻辑

那么首先我们先明确一下对应的 背景:

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

所以为了保证用户的信息安全,那么对于 token 而言就被制定了很多的安全策略,比如:

  1. 动态 token(可变 token
  2. 刷新 token
  3. 时效 token
  4. ...

这些方案各有利弊,没有绝对的完美的策略。

而我们此时所选择的方案就是 时效 token

对于 token 本身是拥有时效的,这个大家都知道。但是通常情况下,这个时效都是在服务端进行处理。而此时我们要在 服务端处理 token 时效的同时,在前端主动介入 token 时效的处理中。 从而保证用户信息的更加安全性。

那么对应到我们代码中的实现方案为:

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

用户被动退出解决方案之被动处理

我们处理了 用户被动退出时的主动处理 ,那么在这我们去处理 用户被动退出时的被动处理

背景:

首先我们需要先明确 被动处理 需要应对两种业务场景:

  1. token 过期
  2. 单用户登录

然后我们一个一个来去看,首先是 token 过期

我们知道对于 token 而言,本身就是具备时效的,这个是在服务端生成 token 时就已经确定的。

而此时我们所谓的 token 过期指的就是:

服务端生成的 token 超过 服务端指定时效 的过程

而对于 单用户登录 而言,指的是:

当用户 A 登录之后,token 过期之前。

用户 A 的账号在其他的设备中进行了二次登录,导致第一次登录的 A 账号被 “顶下来” 的过程。

即:同一账户仅可以在一个设备中保持在线状态

那么明确好了对应的背景之后,接下来我们来看对应的业务处理场景:

从背景中我们知道,以上的两种情况,都是在 服务端进行判断的,而对于前端而言其实是 服务端通知前端的一个过程。

所以说对于其业务处理,将遵循以下逻辑:

  1. 服务端返回数据时,会通过特定的状态码通知前端
  2. 当前端接收到特定状态码时,表示遇到了特定状态:token 时效单用户登录
  3. 此时进行 退出登录 处理

代码:github.com/mohaixiao/v…