Layout

# Layout

Layout包含左侧的 Menu 菜单、顶部的 NavBar 、中间的内容区 Main。

# 1、创建Layout 基础架构

当登录完成之后,那么我们会进入到 Layout 页面,这个 Layout 页面组件位于 Layout/index.vue 中, 所以说想要实现这样的结构,那么我们就需要到对应的 layout 组件中进行。

整个页面分为三部分,所以我们需要先去创建对应的三个组件:

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

然后在 layout/index.vue 中引入这三个组件

<script setup>
    import Navbar from './components/Navbar'
    import Sidebar from './components/Sidebar'
    import AppMain from './components/AppMain'
</script>

完成对应的布局结构

<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>

在 styles 中创建如下 css 文件: variables.scss : 定义常量

// 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;
}


mixin.scss :定义通用的 css

@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:处理 menu 菜单的样式

#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;
    }
  }
}


在 index.scss 中按照顺序导入以上样式文件

@import './variables.scss';
@import './mixin.scss';
@import './sidebar.scss';

在 layout/index.vue 中写入如下样式

<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>

将来要实现 主题更换,所以为 sidebar 赋值动态的背景颜色

<template>
...
    <!-- 左侧 menu -->
    <sidebar
      class="sidebar-container"
      :style="{ backgroundColor: variables.menuBg }"
    />
...
</template>

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

Navbar、Sidebar、AppMain 组件进行初始化代码

<template>
  <div class="">{组件名}</div>
</template>

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

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


AppMain 进行样式处理

<template>
  <div class="app-main">AppMain</div>
</template>

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

<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>


# 2、获取用户基本信息

实现一下 navbar 中的 头像菜单 功能,功能主要分为三个部分: 获取并展示用户信息 element-plus 中的 dropdown 组件使用 退出登录的方案实现

# 2.1 获取并展示用户信息

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

# 2.1.1 定义接口请求方法

在 api/sys.js 中定义如下方法:

/**
 * 获取用户信息
 */
export const getUserInfo = () => {
  return request({
    url: '/sys/profile'
  })
}

因为获取用户信息需要对应的 token ,所以我们可以利用 axios 的 请求拦截器 对 token 进行统一注入,在 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)
  }
)

# 2.1.2 定义调用接口的动作

在 store/modules/user 中写入以下代码:

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
    }
  }
}


# 2.1.3 在权限拦截时触发动作

在 permission.js 中写入以下代码:

    if (to.path === '/login') {
      ...
    } else {
      // 判断用户资料是否获取
      // 若不存在用户信息,则需要获取用户信息
      if (!store.getters.hasUserInfo) {
        // 触发获取用户信息的 action
        await store.dispatch('user/getUserInfo')
      }
      next()
    }
  }

在 store/getters.js 中写入判断用户信息代码:

const getters = {
  userInfo: state => state.user.userInfo,
  /**
   * @returns true 表示已存在用户信息
   */
  hasUserInfo: state => {
    return JSON.stringify(state.user.userInfo) !== '{}'
  }
}

# 3、退出登录方案实现

触发时机分为:主动退出、被动退出(token过期或者被其他人顶下来) 退出登录需要清理掉当前用户缓存数据、清理掉权限相关配置,同时跳转到登录页面

# 3.1 用户主动退出的对应策略:

在 store/modules/user.js 中,添加对应 action

import router from '@/router'

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

为退出登录按钮添加点击事件,触发 logout 的 action

import { useStore } from 'vuex'

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

# 3.2 用户被动退出方案解析

被动处理 需要应对两种业务场景:token 过期、单点登录 我们知道对于 token 而言,本身就是具备时效的,这个是在服务端生成 token 时就已经确定的。 而此时我们所谓的 token 过期指的就是: 服务端生成的 token 超过 服务端指定时效 的过程

单点登录: 当用户 A 登录之后,token 过期之前。 用户 A 的账号在其他的设备中进行了二次登录,导致第一次登录的 A 账号被 “顶下来” 的过程。 即:同一账户仅可以在一个设备中保持在线状态。 以上的两种情况,都是在 服务端进行判断的,而对于前端而言其实是 服务端通知前端的一个过程。 所以说对于其业务处理,将遵循以下逻辑:

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

需要到 单点登录 时,只需要增加一个状态码判断即可。 在 utils/request 的响应拦截器中,增加以下逻辑:

// 响应拦截器
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)
  }
)

# 4、临时 menu 菜单

创建 layout/Sidebar/SidebarMenu 文件

<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 中导入该组件

<template>
  <div class="">
    <h1>占位</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-submenu:子集 menu 菜单 el-menu-item:具体菜单项

# 5、动态menu菜单

动态menu菜单 其实主要是和 动态路由表 配合来去实现 用户权限 的。根据路由表的配置,自动生成对应的 menu 菜单。 当路由表发生变化时,menu 菜单自动发生变化。

实现方案: 定义 路由表 对应 menu 菜单规则 根据规则制定 路由表 根据规则,依据 路由表 ,生成 menu 菜单

实现 动态menu菜单 最核心的关键点其实就在:定义 路由表 对应 menu 菜单规则。

1、对于单个路由规则而言(循环): 如果meta && meta.title && meta.icon :则显示在 menu 菜单中,其中 title 为显示的内容,icon 为显示的图标 如果存在 children :则以 el-sub-menu(子菜单) 展示 否则:则以 el-menu-item(菜单项) 展示 2、否则:不显示在 menu 菜单中

# 5.1 生成项目页面组件

1、创建页面组件 2、生成路由表 3、解析路由表 4、生成 menu 菜单

业务组件包含: pages... 错误页面:error-page 404 401 导入:import 权限列表:permission-list 个人中心:profile 角色列表:role-list 用户信息:user-info 用户管理:user-manage

# 5.2 创建结构路由表

# 1、我们创建的页面并没有全部进行展示:

1.1、如果meta && meta.title && meta.icon :则显示在 menu 菜单中,其中 title 为显示的内容,icon 为显示的图标;如果存在 children : 则以 el-sub-menu(子菜单) 展示 1.2、否则:不显示在 menu 菜单中 2、即不显示页面 不满足 该条件 meta && meta.title && meta.icon

# 2、menu 菜单将具备父子级的结构

[
    {
        "title": "个人中心",
        "path": ""
    },
    {
        "title": "用户",
        "children": [
            {
                "title": "员工管理",
                "path": ""
            },
            {
                "title": "角色列表",
                "path": ""
            },
            {
                "title": "权限列表",
                "path": ""
            }
        ]
    },
    {
        "title": "page",
        "children": [
            {
                "title": "page-child1",
                "path": ""
            },
            {
                "title": "page-child2",
                "path": ""
            }
        ]
    }
]

需要进行 用户权限处理,所以此时我们需要先对路由表进行一个划分: 私有路由表 privateRoutes :权限路由 公有路由表 publicRoutes:无权限路由

生成以下路由表结构:

/**
 * 私有路由表
 */
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 下设置路由出口

<template>
  <div class="app-main">
    <router-view></router-view>
  </div>
</template>