vue后台管理系统

后面可参考下:vue系列(三)——手把手教你搭建一个vue3管理后台基础模板

以下代码项目gitee地址

文章目录

    • 1. 初始化前端项目
      • 初始化项目
      • 添加加载效果
      • 配置 vite.config.js
    • 2. 使用路由
      • 安装路由
      • 配置路由
      • 配置@别名和跳转
        • 安装path
        • vite.config.js
        • jsconfig.json
      • main.js中使用路由
    • 3. 使用elment-plus
      • 安装elment-plus
      • main.js中使用elment-plus
    • 4. 使用pinia
      • 安装pinia
      • 配置pinia
        • 创建store/index.js
        • 创建store/counter.js
      • main.js中引入
      • 组件中使用
    • 5. 使用axios
      • 安装axios
      • 编写request.js
      • 编写api请求接口
      • 组件中使用axios
    • 6. 使用nprogress
      • 安装nprogress
      • 封装nprogress.js
      • 路由中使用nprogress
    • 7. 引入iconfont
      • 下载iconfont
      • main.js中引入
    • 8. 封装ELMessage
    • 9. 登录功能
      • 配置登录的路由
      • login.vue
      • store/user.js
      • api/loginApi.js
    • 10.后台页面布局
      • 配置登录成功后的路由
      • 拆分组件
        • 创建layout/index.vue
        • 创建store/layout.js
        • 创建layout/components/Sider.vue
        • 创建layout/Main.vue
        • 创建layout/Breadcrumb.vue
        • 创建layout/TagsView组件
        • 创建layout/components/Demo.vue
    • 11. 菜单
      • 搭建静态菜单路由
        • 配置主页/用户/角色/菜单路由
        • 使用el-menu创建侧边栏菜单
        • 创建views/Home.vue
        • 创建views/404/NotFound.vue
      • 实现动态路由菜单
        • 调整路由和菜单
          • 调整路由
          • 调整菜单
        • 后台菜单和路由数据返回示例
          • menu.json
          • router.json
          • 修改loginApi.js
          • 修改request.js
          • 修改router/index.js
          • 修改user.js
          • 创建store/menu.js
        • 修改菜单栏组件Sider.vue
        • 创建TreeMenu.vue递归组件
        • 解决地址栏刷新问题
          • 修改router/index.js
          • 修改menu.js
    • 12.全屏功能
      • 安装screenfull
      • 使用screenfull
    • 13. 面包屑
      • 数据
      • 修改menus.js
      • 修改Breadcrumb.vue
    • 14. tagsView
      • TagsView.vue
      • TagsView.js
    • 15. vue指令控制权限按钮显示
      • 后台返回权限数据
      • 创建指令文件perms.js
      • main.js中注册该指令
      • loginApi.js中添加接口
      • 修改store/menu.js
      • User.vue中使用

1. 初始化前端项目

初始化项目

可参考:vite官网 https://vitejs.cn/guide/#scaffolding-your-first-vite-project

npm init vite@latest mushan-vue3-admin

npm install

npm run dev

添加加载效果

在index.html中的id为app中,写入

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + Vue</title>
  <style>
    body {
      padding: 0px;
      margin: 0px;
    }

    .loading {
      display: flex;
      height: 100vh;
      width: 100vw;
      background: #92b1d7;
      justify-content: center;
      align-items: center;
    }

    .loading .content {
      position: relative;
      display: flex;
      justify-content: space-around;
      align-items: center;
      margin: 15px;
      border-radius: 4px;
      padding: 10px;
    }

    .circle-3 {
      width: 60px;
      height: 60px;
      border-radius: 50%;
      display: inline-block;
      position: relative;
      border: 3px solid;
      border-color: #fff #fff transparent transparent;
      animation: rotation 1s linear infinite;
    }

    .circle-3::after,
    .circle-3::before {
      content: "";
      position: absolute;
      left: 0;
      right: 0;
      top: 0;
      bottom: 0;
      margin: auto;
      border-radius: 50%;
      border: 3px solid;
      animation: rotation-back 0.5s linear infinite;
    }

    .circle-3::after {
      border-color: transparent #f6b352 #f6b352 transparent;
      width: 52px;
      height: 52px;
    }

    .circle-3::before {
      border-color: transparent transparent #fff #fff;
      width: 44px;
      height: 44px;
    }

    @keyframes rotation {
      0% {
        transform: rotate(0deg);
      }

      100% {
        transform: rotate(360deg);
      }
    }

    @keyframes rotation-back {
      0% {
        transform: rotate(0deg);
      }

      100% {
        transform: rotate(-360deg);
      }
    }
  </style>
</head>

<body>
  <div id="app">
    <div class="loading">
      <div class="content">
        <div class="circle-3"></div>
      </div>
    </div>
  </div>
  <script type="module" src="/src/main.js"></script>
</body>

</html>

配置 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    hmr: true,
    port: 5174,
  },
  resolve: {
    alias: {
      '@':path.resolve(__dirname,'./src')
    }
  }
})

2. 使用路由

安装路由

npm i vue-router@4 -S

配置路由

import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";


// 路由信息
const routes = [
    {
        path: '/',
        name: 'home',
        component: ()=>import('@/views/index.vue')
    },
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

router.beforeEach((to,from,next)=>{
    nprogress.start()
    next()
})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;

配置@别名和跳转

安装path

npm i path

vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@':path.resolve(__dirname,'./src')
    }
  }
})

jsconfig.json

与vite.config.js在同一级目录下

{
    "compilerOptions": {
        "baseUrl": "./",
        "paths": {
            "@/*": [
                "src/*"
            ],
        }
    },
    "exclude": [
        "node_modules",
        "dist"
    ],
    "include": [
        "src/**/*"
    ]
}

main.js中使用路由

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

import router from '@/router'

const app = createApp(App)
app.mount('#app')
app.use(router)

3. 使用elment-plus

安装elment-plus

npm install element-plus --save

main.js中使用elment-plus

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

import router from '@/router'

const app = createApp(App)
app.mount('#app')
app.use(router)
app.use(ElementPlus)

4. 使用pinia

可参考:Vue3中的pinia使用(收藏版)

安装pinia

npm install pinia --save

配置pinia

创建store/index.js

import { createPinia } from 'pinia'

const pinia = createPinia()

export default pinia

创建store/counter.js

import { defineStore } from 'pinia'

export const useCounter =  defineStore('counter',{
    state: () => ({
		count:99
	}),
    getters: {

    },
    actions: {
        
    }
})

main.js中引入

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

import router from '@/router'
import pinia from '@/store'



const app = createApp(App)

app.use(router)
app.use(pinia)
app.use(ElementPlus)

app.mount('#app')

组件中使用

<template>
    {{ counterStore.count }}
    <el-button @click="visitStore">你好</el-button>
</template>

<script setup>
    import {useCounter}  from '@/store/counter'
    const counterStore = useCounter()

    function visitStore() {
        console.log(counterStore.count);
    }
</script>

<style lang="scss">

</style>

5. 使用axios

可参考:Vue3使用axios的配置教程

安装axios

npm install axios --save

编写request.js

import axios from 'axios'
import Messager from './messager'; // 在下面封装了

const instance = axios.create({
    baseURL: 'http://127.0.0.1:8080/api',
    timeout: 10000
})

instance.interceptors.request.use((config)=>{
    return config;
})

instance.interceptors.response.use(response=>{
    if(response.data.errno == 0) {
        return Promise.resolve(response.data.data)
    } else {
        if(response.data.errno == 501) {
            Messager.error('请重新登录')
            window.location.href = '/login'
        } else {
            Messager.error(response.data.errmsg)
            return Promise.reject(new Error(response.data.errmsg))
        }
    }
})

export default instance

编写api请求接口

import request from '@/utils/request'

export function getCaptchaImage()  {
    return request({
        url: 'captchaImage',
    })
}

export function login(data)  {
    return request({
        method:'post',
        url: 'user/login',
        data
    })
}

组件中使用axios

<template>
    <el-button @click="refreshCaptchaImage">验证码</el-button>
</template>

<script setup>
    import {getCaptchaImage} from '@/api/loginApi'

    async function refreshCaptchaImage() {
        let result = await getCaptchaImage()
        console.log(result);
    }
</script>

<style lang="scss">

</style>

6. 使用nprogress

安装nprogress

npm i nprogress -S

封装nprogress.js

import Nprogress from 'nprogress'
import 'nprogress/nprogress.css'

const nprogress = Nprogress.configure({
    easing: 'ease', // 动画方式
    speed: 1000, // 递增进度条的速度
    showSpinner: false, // 是否显示加载ico
    trickleSpeed: 200, // 自动递增间隔
    minimum: 0.3, // 更改启动时使用的最小百分比
    parent: 'body', //指定进度条的父容器
})

export default nprogress

路由中使用nprogress

import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";


// 路由信息
const routes = [
    {
        path: '/',
        name: 'home',
        component: ()=>import('@/views/index.vue')
    },
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

router.beforeEach((to,from,next)=>{
    nprogress.start()
    next()
})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;

7. 引入iconfont

下载iconfont

下载iconfont相关资源到本地,添加到assets/iconfont目录下

main.js中引入

import { createApp } from 'vue'
import './style.css'

import '@/assets/iconfont/iconfont.css' // 引入iconfont的css文件

import App from './App.vue'

8. 封装ELMessage

import { ElMessage } from "element-plus";
const Messager = {
    ok(msg){
        ElMessage.success(msg)
    },
    error(msg) {
        ElMessage.error(msg)
    }
}
export default Messager

9. 登录功能

配置登录的路由

import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";


// 路由信息
const routes = [
    {
        path: '/',
        name: 'home',
        component: ()=>import('@/views/index.vue')
    },
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

router.beforeEach((to,from,next)=>{
    nprogress.start()
    next()
})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;

login.vue

在这里插入图片描述

<template>
    <div class="login-page">
        <div class="login-container">
            <h1 class="login-title">登录</h1>
            <el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules" class="login-form">
                <el-form-item prop="username">
                    <el-input v-model="loginFormData.username" prop="username">
                        <template #prefix>
                            <i class="iconfont icon-yonghu"></i>
                        </template>
                    </el-input>
                </el-form-item>

                <el-form-item prop="password">
                    <el-input v-model="loginFormData.password">
                        <template #prefix>
                            <i class="iconfont icon-mima"></i>
                        </template>
                    </el-input>
                </el-form-item>
                <el-form-item prop="code">
                    <div class="login-code">
                        <el-input v-model="loginFormData.code" prop="password">
                            <template #prefix>
                                <i class="iconfont icon-yanzhengma"></i>
                            </template>
                        </el-input>
                        <div class="code-img">
                            <img :src="codeImg" @click="getCodeImg">
                        </div>
                    </div>
                </el-form-item>
                <el-form-item>
                    <el-button type="primary" style="width:100%;" @click="submitLoginForm">登录</el-button>
                </el-form-item>
            </el-form>
        </div>
    </div>
</template>

<script setup>
import {getCaptchaImage} from '@/api/loginApi'

import useUser from '@/store/user'
import { ref, reactive,getCurrentInstance, onMounted } from 'vue'
import { useRouter } from 'vue-router'

const { proxy } = getCurrentInstance()
const userStore = useUser()
const router = useRouter()


const codeImg = ref('')

const loginFormData = reactive({
    username: 'admin',
    password: '123456',
    uuid: '',
    code: ''
})
const loginFormRules = {
    username: [
        {required:true,message: '用户名不能为空',trigger: 'blur'}
    ],
    password: [
        {required:true,message: '密码不能为空',trigger: 'blur'}
    ],
    code: [
        {required:true,message: '验证码不能为空',trigger: 'blur'}
    ],
}

const loginFormRef = ref(null)
function submitLoginForm() {
    loginFormRef.value.validate(async(valid,fields)=>{

        if(!valid) {
            proxy.Messager.error('请填写完整')
            return
        }

        console.log(userStore);
        let result  = await userStore.doLogin(loginFormData)
        router.replace('/')

    })
}

function getCodeImg() {
    getCaptchaImage().then(res=>{
        codeImg.value = "data:image/gif;base64," + res.img
        loginFormData.uuid = res.uuid
    })
}

onMounted(()=>{
    getCodeImg()
})

</script>

<style lang="scss" scoped>
    .iconfont {
        font-size: 16px;
    }
    .login-page {
        height: 100vh;
        background-image: url(@/assets/bg.jpg);
        background-position: center;
        background-size: cover;
        display: flex;
        justify-content: center;
        align-items: center;
        .login-container {
            width: 350px;
            padding: 20px;
            background: rgba(255, 255, 255, 1);
            border-radius: 5px;
            .login-title {
                font-size: 26px;
                text-align: center;
                margin-bottom: 15px;
            }

            .login-code {
                display: flex;
                .code-img {
                    height: 34px;
                    width: 180px;
                    margin-left: 10px;
                    border-radius: 5px;
                    cursor: pointer;
                    background-color: pink;
                    overflow: hidden;
                    img {
                        width: 100%;
                        height: 100%;
                        object-fit: cover;
                        transform: scale(1.2);
                    }
                }
            }
        }
    }
</style>

store/user.js

将登录获取的token存入localStorage

import { defineStore } from 'pinia'

import { login } from '@/api/loginApi'

function retrieveLocalToken() {
    return localStorage.getItem('token') || ''
}

export default defineStore('user',{
    state: () => {
        return {
            token: retrieveLocalToken() // 当刷新页面时, 这个会加载一次
        }
    },
    getters: {

    },
    actions: {
        doLogin(data) {
            return new Promise((resolve, reject) => {
                login(data).then(res=>{
                    this.token = res // 同样先存入到pinia中
                    localStorage.setItem('token', res)
                    console.log('login',res);
                    resolve(data)
                }).catch(err=>{
                    reject(err)
                })
            })
        }
    }
})

api/loginApi.js

import request from '@/utils/request'

export function getCaptchaImage()  {
    return request({
        url: 'captchaImage',
    })
}

export function login(data)  {
    return request({
        method:'post',
        url: 'user/login',
        data
    })
}

10.后台页面布局

登录成功之后,会跳到主页,主页大概如下布局,可以先参考vue3 + elment-plus实现后台布局的静态页面布局,然后把它划分成不同的组件,不同组件的数据共享通过pinia这个store来管理。

在这里插入图片描述

配置登录成功后的路由

import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";


// 路由信息
const routes = [
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    },
    {
        path: '/',
        name: 'home',
        component: ()=>import('@/layout/index.vue')
    },
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

router.beforeEach((to,from,next)=>{
    nprogress.start()
    next()
})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;

拆分组件

创建layout/index.vue

Layout组件引入Sider和Main组件


<template>
    <div class="layout">
        <Sider/>
        <Main></Main>
    </div>
</template>

<script setup>
import Sider from './components/Sider.vue';
import Main from './components/Main.vue';
import { ref, reactive } from 'vue'

</script>

<style lang="scss" scoped>
.layout {
    display: flex;
}

</style>

创建store/layout.js

将组件的共享数据存入pinia

import { defineStore } from 'pinia'

export default defineStore('layout', {
    state: ()=> {
        return {
            isExpand: true, // 侧边栏是否展开
        }
    },
    getters: {

    },
    actions: {
        // 切换侧边栏
        toggleSider() {
            console.log('切换侧边栏', this.isExpand);
            this.isExpand = !this.isExpand
        }
    }
})

创建layout/components/Sider.vue

isExpand是存放在pinia中的数据

<template>
    <div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">

        <div class="sider-top">
            <h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
            <i v-else class="iconfont icon-graphcool site-icon"></i>
        </div>
        <div class="sider-body">
            <el-scrollbar>
                <ul>
                    <li class="li-item">1</li>
                    <li class="li-item">2</li>
                    <li class="li-item">3</li>
                    <li class="li-item">4</li>
                    <li class="li-item">5</li>
                    <li class="li-item">6</li>
                    <li class="li-item">7</li>
                    <li class="li-item">8</li>
                    <li class="li-item">9</li>
                    <li class="li-item">9</li>
                    <li class="li-item">9</li>
                    <li class="li-item">9</li>
                    <li class="li-item">9</li>
                </ul>
            </el-scrollbar>
        </div>
    </div>
</template>

<script setup>
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'

const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式

</script>

<style lang="scss">
.sider {
    width: 220px;
    height: 100vh;
    background-color: #294256;
    position: relative;

    box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);

    flex-shrink: 0;

    .sider-top {
        height: 50px;

        display: flex;
        align-items: center;
        justify-content: center;

        overflow: hidden;

        .site-title {
            white-space: nowrap;
            font-weight: bold;
            color: #fff;
        }

        .site-icon {
            font-size: 20px;
            color: #27ae60;
        }
    }

    .sider-body {
        position: absolute;
        top: 50px;
        left: 0;
        right: 0;
        bottom: 0;

        background-color: #294256;

        .li-item {
            height: 50px;
            margin: 10px;
            background-color: #294256;
            color: #fff;

            display: flex;
            align-items: center;
            justify-content: center;
        }
    }

}
</style>

创建layout/Main.vue

<template>
  <div class="main">

        <div class="main-header">
            <div class="main-header-top">
                <div class="main-header-top-left">
                    <div class="hamburger" @click="layoutStore.toggleSider">
                        <i :class="['iconfont', { 'icon-shousuocaidan': isExpand }, { 'icon-shousuocaidan-copy': !isExpand }]"></i>
                    </div>
                    <Breadcrumb />
                </div>
                <div class="main-header-top-right">
                    <div class="gitee mlr8 pointer">
                        <i class="iconfont icon-gitee"></i>
                    </div>
                    <div class="fullscreen mlr8">
                        <i :class="['iconfont', 'pointer', { 'icon-quanping_o': !isFullScreen, 'icon-quxiaoquanping_o': isFullScreen }]"></i>
                    </div>
                    <div class="theme-mode mlr8">
                        <el-switch inline-prompt :active-icon="Check" :inactive-icon="Close" />
                    </div>
                    <div class="avatar-box mlr8 pointer">
                        <el-dropdown>
                            <span class="el-dropdown-link">
                                <img class="avatar" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" alt="">
                            </span>
                            <template #dropdown>
                                <el-dropdown-menu>
                                    <el-dropdown-item>个人中心</el-dropdown-item>
                                    <el-dropdown-item divided>退出登录</el-dropdown-item>
                                </el-dropdown-menu>
                            </template>
                        </el-dropdown>
                    </div>
                </div>
            </div>
            <TagsView/>
        </div>
        <div class="main-body">
            <Demo/>
            <!-- <router-view></router-view> -->
        </div>
    </div>
</template>

<script setup>
import Breadcrumb from './Breadcrumb.vue'
import useLayout from '@/store/layout'
import Demo from './Demo.vue'
import { storeToRefs } from 'pinia'
import { ref, reactive } from 'vue'
import TagsView from './TagsView.vue'

const isFullScreen = ref(false)
const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore)


</script>

<style lang="scss">
.main {
    flex: 1;
    overflow: hidden;

    position: relative;

    .main-header {
        border-bottom: 1px solid #ccc;
        box-shadow: 0 3px 5px 0 rgb(0 0 0 / 10%);

        .main-header-top {
            height: 50px;
            box-shadow: 0 3px 10px 0 rgb(0 0 0 / 6%);
            background: #fff;
            border-bottom: 1px solid rgba(0, 0, 0, .1);

            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 0 10px;

            .main-header-top-left {
                display: flex;
                align-items: center;

                .hamburger {
                    cursor: pointer;
                    padding: 8px;
                    margin: 5px;

                    i {
                        font-size: 1.2em;
                    }

                }
            }

            .main-header-top-right {
                display: flex;
                align-items: center;
                .avatar {
                    width: 40px;
                    height: 40px;
                    border-radius: 50%;
                }
                .gitee {
                    color: #c71d23;
                }
            }


        }

        
    }

    .main-body {
        position: absolute;
        top: 83px;
        left: 0;
        right: 0;
        bottom: 0;
    }

}

i.iconfont {
    font-size: 1.6em;
}

.mlr8 {
    margin-left: 8px;
    margin-right: 8px;
}
</style>

创建layout/Breadcrumb.vue

<template>
    <el-breadcrumb separator="/" stsyle="color: #303133;">
        <el-breadcrumb-item :to="{ path: '/' }">系统管理</el-breadcrumb-item>
        <el-breadcrumb-item><a href="/">用户管理</a></el-breadcrumb-item>
        <el-breadcrumb-item>添加用户</el-breadcrumb-item>
    </el-breadcrumb>
</template>

<script setup>

</script>

<style lang="scss">

</style>

创建layout/TagsView组件

<template>
    <div class="main-header-tags-wrapper">
        <el-scrollbar>
            <div class="main-header-tags">
                <div class="tag-item">1</div>
                <div class="tag-item">2</div>
                <div class="tag-item">3</div>
                <div class="tag-item">4</div>
                <div class="tag-item">5</div>
                <div class="tag-item">6</div>
                <div class="tag-item">7</div>
                <div class="tag-item">8</div>
                <div class="tag-item">9</div>
            </div>
        </el-scrollbar>
    </div>
</template>

<script setup>

</script>

<style lang="scss">
.main-header-tags-wrapper {

padding: 0 10px;


.main-header-tags {
    height: 32px;

    display: flex;
    align-items: center;

    .tag-item {
        width: 160px;
        height: 26px;
        margin-right: 10px;

        border: 1px solid #ccc;

        background-color: #fff;

        flex-shrink: 0;

        display: flex;
        align-items: center;
        justify-content: center;
    }
}
}
</style>

创建layout/components/Demo.vue

<template>
    <div class="main-content-wrapper">
        <div class="content">
            <el-scrollbar>
                <el-timeline>
                    <el-timeline-item v-for="(activity, index) in activities" :key="index" :icon="activity.icon"
                        :type="activity.type" :color="activity.color" :size="activity.size" :hollow="activity.hollow"
                        :timestamp="activity.timestamp">
                        {{ activity.content }}
                    </el-timeline-item>
                </el-timeline>
            </el-scrollbar>
        </div>
    </div>
</template>

<script setup>
import { Expand, Fold, MoreFilled } from '@element-plus/icons-vue'

const activities = [
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    }, {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
]
</script>

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

11. 菜单

搭建静态菜单路由

这一步,我们将获得如下的效果
在这里插入图片描述

配置主页/用户/角色/菜单路由

import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";


// 路由信息
const routes = [
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    },
    {
        path: '/',
        name: 'layout',
        redirect:'/home',
        component: ()=>import('@/layout/index.vue'),
        children: [
            {
                path: 'home',
                name: 'home',
                component: ()=>import('@/views/Home.vue'),
            },
            {
                path: 'user',
                name: 'user',
                component: ()=>import('@/views/sys/user.vue'),
            },
            {
                path: 'role',
                name: 'role',
                component: ()=>import('@/views/sys/role.vue'),
            },
            {
                path: 'menu',
                name: 'menu',
                component: ()=>import('@/views/sys/menu.vue'),
            }
        ]
    },
	// 匹配404页面
    {
        path:'/:pathMatch(.*)*',
        name: 'notFound',
        component: ()=>import('@/views/404/NotFound.vue')
    },
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

router.beforeEach((to,from,next)=>{
    nprogress.start()
    next()
})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;

使用el-menu创建侧边栏菜单

  1. el-menu就是一个ul,而el-subm-menu和el-menu-item都是一个li,其中el-sub-menu这个li中里面会嵌套一个div和一个ul>li,里面的这个div(使用title插槽)会显示出来作为菜单,里面的ul>li会作为收缩菜单
  2. 当收缩的时候,会给el-menu生成的ul(也就是最外面的ul)加上一个el-menu–collapse的类名,它会把菜单中span的文字给隐藏掉,这样就只会显示图标了
<template>
    <div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">

        <div class="sider-top">
            <h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
            <i v-else class="iconfont icon-graphcool site-icon"></i>
        </div>
        <div class="sider-body">
            <el-scrollbar>

                <el-menu 
                    :collapse="isCollapse" 
                    router 
                    collapse-transition text-color="#eee" 
                    :default-openeds="['/sys']" 
                    default-active="/home"
                    background-color="#294256" class="menu-bar">

                    <el-menu-item index="/home">
                        <i class="iconfont icon-home-line"></i>
                        <span>主页</span>
                    </el-menu-item>

                    <el-sub-menu index="/sys">

                        <template #title>
                            <i class="iconfont icon-shezhi"></i>
                            <span>系统管理</span>
                        </template>

                        <el-menu-item index="/user">
                            <i class="iconfont icon-yonghuguanli"></i>
                            <span>用户管理</span>
                        </el-menu-item>

                        <el-menu-item index="/role">
                            <i class="iconfont icon-jiaoseguanli"></i>
                            <span>角色管理</span>
                        </el-menu-item>

                        <el-menu-item index="/menu">
                            <i class="iconfont icon-icon_caidanguanli"></i>
                            <span>菜单管理</span>
                        </el-menu-item>

                    </el-sub-menu>

                    <el-sub-menu index="/test">

                        <template #title>
                            <i class="iconfont icon-graphcool"></i>
                            <span>多级菜单</span>
                        </template>

                        <el-menu-item index="/test-1">
                            <i class="iconfont icon-graphcool"></i>
                            <span>test-1</span>
                        </el-menu-item>

                        <el-sub-menu index="test-2" class="nested-sub-menu">

                            <template #title>
                                <i class="iconfont icon-graphcool"></i>
                                <span>test-2</span>
                            </template>

                            <el-menu-item index="/test-2-1">
                                <i class="iconfont icon-graphcool"></i>
                                <span>test-2-1</span>
                            </el-menu-item>
                            <el-menu-item index="/test-2-2">
                                <i class="iconfont icon-graphcool"></i>
                                <span>test-2-2</span>
                            </el-menu-item>

                        </el-sub-menu>

                    </el-sub-menu>

                </el-menu>
            </el-scrollbar>
        </div>
    </div>
</template>

<script setup>
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'

const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式

const isCollapse = computed({
    get() {
        return !isExpand.value
    }
})
watch(isExpand, (newVal, oldVal) => {
    // console.log('监听到变化');
})

</script>

<style lang="scss">
.sider {
    width: 220px;
    height: 100vh;
    background-color: #294256;
    position: relative;

    box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);

    flex-shrink: 0;

    .sider-top {
        height: 50px;

        display: flex;
        align-items: center;
        justify-content: center;

        overflow: hidden;

        .site-title {
            white-space: nowrap;
            font-weight: bold;
            color: #fff;
        }

        .site-icon {
            font-size: 20px;
            color: #27ae60;
        }
    }

    .sider-body {
        position: absolute;
        top: 50px;
        left: 0;
        right: 0;
        bottom: 0;

        background-color: #294256;

        .menu-bar {
            .iconfont {
                margin-right: 10px;
            }
        }
    }

}

.el-menu {
    border-right: none; // 修复边缘白边
}

ul.el-menu--inline, .nested-sub-menu div  {
    background-color: #1f2d3d !important; // 二级菜单深暗色
}
</style>

创建views/Home.vue

Home.vue可作为其它组件放入到Main.vue组件的main-body中的路由出口的模板,这样就不会让右边整体出现垂直滚动条(如下图),其它组件可以自定义布局方式,
在这里插入图片描述

<template>
    <div class="main-content-wrapper">
        <div class="content">
            <el-scrollbar>
                <el-timeline>
                    <el-timeline-item v-for="(activity, index) in activities" :key="index" :icon="activity.icon"
                        :type="activity.type" :color="activity.color" :size="activity.size" :hollow="activity.hollow"
                        :timestamp="activity.timestamp">
                        {{ activity.content }}
                    </el-timeline-item>
                </el-timeline>
            </el-scrollbar>
        </div>
    </div>
</template>

<script setup>
import { Expand, Fold, MoreFilled } from '@element-plus/icons-vue'

const activities = [
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
    {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    }, {
        content: 'Custom icon',
        timestamp: '2018-04-12 20:46',
        size: 'large',
        type: 'primary',
        icon: MoreFilled,
    },
]
</script>

<style lang="scss">
.main-content-wrapper {
    overflow: auto;
    box-sizing: border-box;
    width: 100%;
    height: 100%;
    padding: 20px;
    background-clip: content-box;

    .content {
        width: 100%;
        height: 100%;
        overflow: auto;
        background-color: #fff;
        border-radius: 8px;

        padding: 10px 0 10px 10px;
        box-sizing: border-box;

        border: 1px solid red;
    }
}
</style>

创建views/404/NotFound.vue

这里就展示简单的返回下

<template>
    <div class="main-content-wrapper">
        <div>
            <h1>页面找丢了。。。</h1>
            <el-button type="primary" @click="goBack">返回</el-button>
        </div>
    </div>
</template>

<script setup>
    function goBack() {
        window.history.go(-1)
    }
</script>

<style lang="scss">
.main-content-wrapper {
    overflow: auto;
    box-sizing: border-box;
    width: 100%;
    height: 100%;
    padding: 20px;
    background-clip: content-box;

    display: flex;
    align-items: center;
    justify-content: center;
}
</style>

实现动态路由菜单

不同用户登录进来,需要根据当前用户拥有的菜单显示在侧边栏,并且动态添加路由到router中。也就是说,用户一登陆完成,我们就应该请求后台去拿到用户拥有的所有菜单,组装侧边栏菜单,并且要动态的添加路由。

调整路由和菜单

我们需要做如下的事情,但是在做下面的事情之前,我们先调整一下我们的菜单,确保这样是可用的,然后再接入后台数据。

  1. 需要获取左侧菜单栏的数据,然后递归遍历出来
  2. 将路由添加到router里面
调整路由
  • 我们注意到,vue里面的路由,如果是以/直接开头,它就会忽略父路由的路径,而直接去匹配,而如果不是以/开头,则会拼接上父路径去匹配,为了方便,就全部以/开头。
  • 我们把所有的路由都作为layout的子路由,所以后面我们就直接添加到layout的路由下面就行了
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";


// 路由信息
const routes = [
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    },
    {
        path: '/',
        name: 'layout',
        component: ()=>import('@/layout/index.vue'),
        children: [
            {
                path: '/home', // 最好以/开头, 如果不以/开头,那么路由到这个组件就需要拼接上父路径
                name: 'home',
                component: ()=>import('@/views/Home.vue'),
            },
            {
                path: '/sys/user',
                name: 'user',
                component: ()=>import('@/views/sys/user.vue'),
            },
            {
                path: '/sys/role',
                name: 'role',
                component: ()=>import('@/views/sys/role.vue'),
            },
            {
                path: '/sys/menu',
                name: 'menu',
                component: ()=>import('@/views/sys/menu.vue'),
            },
            {
                path: '/test/test_1',
                name: 'test_1',
                component: ()=>import('@/views/test/test_1.vue'),
            },
            {
                path: '/test/test2/test_2_1',
                name: 'test_2_1',
                component: ()=>import('@/views/test/test2/test_2_1.vue'),
            },
            {
                path: '/test/test2/test_2_2',
                name: 'test_2_2',
                component: ()=>import('@/views/test/test2/test_2_2.vue'),
            },
        ]
    },
    {
        path:'/:pathMatch(.*)*',
        name: 'notFound',
        component: ()=>import('@/views/404/NotFound.vue')
    },
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

router.beforeEach((to,from,next)=>{
    nprogress.start()
    next()
})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;
调整菜单

这里我们只需要对着路由写index的路径即可。还有注意的是,如果用户是直接在地址栏输入的路径而跳转的话,我们也需要让对应的菜单高亮,我们监听路由即可。

<template>
    <div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">

        <div class="sider-top">
            <h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
            <i v-else class="iconfont icon-graphcool site-icon"></i>
        </div>
        <div class="sider-body">
            <el-scrollbar>

                <el-menu 
                    :collapse="isCollapse" 
                    router 
                    collapse-transition text-color="#eee" 
                    :default-openeds="['/sys']" 
                    :default-active="activeMenu"
                    background-color="#294256" class="menu-bar">

                    <el-menu-item index="/home">
                        <i class="iconfont icon-home-line"></i>
                        <span>主页</span>
                    </el-menu-item>

                    <el-sub-menu index="/sys">

                        <template #title>
                            <i class="iconfont icon-shezhi"></i>
                            <span>系统管理</span>
                        </template>

                        <el-menu-item index="/sys/user">
                            <i class="iconfont icon-yonghuguanli"></i>
                            <span>用户管理</span>
                        </el-menu-item>

                        <el-menu-item index="/sys/role">
                            <i class="iconfont icon-jiaoseguanli"></i>
                            <span>角色管理</span>
                        </el-menu-item>

                        <el-menu-item index="/sys/menu">
                            <i class="iconfont icon-icon_caidanguanli"></i>
                            <span>菜单管理</span>
                        </el-menu-item>

                    </el-sub-menu>

                    <el-sub-menu index="/test">

                        <template #title>
                            <i class="iconfont icon-graphcool"></i>
                            <span>多级菜单</span>
                        </template>

                        <el-menu-item index="/test/test_1">
                            <i class="iconfont icon-graphcool"></i>
                            <span>test_1</span>
                        </el-menu-item>

                        <el-sub-menu index="/test/test2" class="nested-sub-menu">

                            <template #title>
                                <i class="iconfont icon-graphcool"></i>
                                <span>test_2</span>
                            </template>

                            <el-menu-item index="/test/test2/test_2_1">
                                <i class="iconfont icon-graphcool"></i>
                                <span>test_2_1</span>
                            </el-menu-item>
                            <el-menu-item index="/test/test2/test_2_2">
                                <i class="iconfont icon-graphcool"></i>
                                <span>test_2_2</span>
                            </el-menu-item>

                        </el-sub-menu>

                    </el-sub-menu>

                </el-menu>
            </el-scrollbar>
        </div>
    </div>
</template>

<script setup>
import { ref,reactive } from 'vue'
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useRouter,useRoute } from 'vue-router'

const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式

const isCollapse = computed({
    get() {
        return !isExpand.value
    }
})
watch(isExpand, (newVal, oldVal) => {
    // console.log('监听到变化');
})

const activeMenu = ref('/home') // 需要监听到当前的路由, 让它高亮, 因为用户有可能手动地址栏输入
const router = useRouter()
const route = useRoute()

watch(()=>route.fullPath, (newVal,oldVal)=>{
    console.log('监听到当前的路由', newVal);
    activeMenu.value = newVal;
}, {immediate:true})


</script>

<style lang="scss">
.sider {
    width: 220px;
    height: 100vh;
    background-color: #294256;
    position: relative;

    box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);

    flex-shrink: 0;

    .sider-top {
        height: 50px;

        display: flex;
        align-items: center;
        justify-content: center;

        overflow: hidden;

        .site-title {
            white-space: nowrap;
            font-weight: bold;
            color: #fff;
        }

        .site-icon {
            font-size: 20px;
            color: #27ae60;
        }
    }

    .sider-body {
        position: absolute;
        top: 50px;
        left: 0;
        right: 0;
        bottom: 0;

        background-color: #294256;

        .menu-bar {
            .iconfont {
                margin-right: 10px;
                font-size: 1.4em;
            }
        
        }
    }

}

.el-menu {
    border-right: none; // 修复边缘白边
}

ul.el-menu--inline, .nested-sub-menu div  {
    background-color: #1f2d3d !important; // 二级菜单深暗色
}
</style>

后台菜单和路由数据返回示例

menu.json
{
  "errno": 0,
  "errmsg": "成功",
  "data": [
    {
      "id": 1,
      "parentId": 0,
      "title":"主页",
      "icon":"iconfont icon-home-line",
      "url":"/home",
      "menuType": "C",
      "component":"@/views/Home.vue"
    },
    {
      "id": 2,
      "parentId": 0,
      "title":"系统设置",
      "icon":"iconfont icon-shezhi",
      "url":"/sys",
      "menuType": "M",
      "component":"",
      "children": [
        {
          "id": 3,
          "parentId": 2,
          "title":"用户管理",
          "icon":"iconfont icon-yonghuguanli",
          "url":"/sys/user",
          "menuType": "C",
          "component":"@/views/sys/user.vue"
        },
        {
          "id": 4,
          "parentId": 2,
          "title":"角色管理",
          "icon":"iconfont icon-jiaoseguanli",
          "url":"/sys/role",
          "menuType":"C",
          "component":"@/views/sys/role.vue"
        },
        {
          "id": 5,
          "parentId": 2,
          "title":"菜单管理",
          "icon":"iconfont icon-icon_caidanguanli",
          "url":"/sys/menu",
          "menuType":"C",
          "component":"@/views/sys/menu.vue"
        }
      ]
    },
    {
      "id": 6,
      "parentId": 0,
      "title":"多级菜单",
      "icon":"iconfont icon-graphcool",
      "url":"/test",
      "component":"",
      "menuType":"M",
      "children": [
        {
          "id": 7,
          "parentId": 6,
          "title":"test_1",
          "icon":"iconfont icon-graphcool",
          "url":"/test/test_1",
          "menuType":"C",
          "component":"@/views/test/test_1.vue"
        },
        {
          "id": 8,
          "parentId": 2,
          "title":"test_2",
          "icon":"iconfont icon-graphcool",
          "url":"/test/test_2",
          "menuType":"M",
          "component":"",
          "children":[
            {
              "id": 9,
              "parentId": 8,
              "title":"test_2_1",
              "icon":"iconfont icon-graphcool",
              "menuType":"C",
              "url":"/test/test_2/test_2_1",
              "component":"@/views/test/test_2_1.vue"
            },
            {
              "id": 10,
              "parentId": 8,
              "title":"test_2_2",
              "icon":"iconfont icon-graphcool",
              "menuType":"C",
              "url":"/test/test_2/test_2_2",
              "component":"@/views/test/test_2_2.vue"
            }
          ]
        }

      ]
    }
  ]
}


router.json
{
  "errno": 0,
  "errmsg": "成功",
  "data": [
    {
      "path": "/home",
      "name": "home",
      "component": "@/views/Home.vue"
    },
    {
      "path": "/sys/user",
      "name": "user",
      "component": "@/views/sys/user.vue"
    },
    {
      "path": "/sys/role",
      "name": "role",
      "component": "@/views/sys/role.vue"
    },
    {
      "path": "/sys/menu",
      "name": "menu",
      "component": "@/views/sys/menu.vue"
    },
    {
      "path": "/test/test_1",
      "name": "test_1",
      "component": "@/views/test/test_1.vue"
    },
    {
      "path": "/test/test_2/test_2_1",
      "name": "test_2_1",
      "component": "@/views/test/test2/test_2_1.vue"
    },
    {
      "path": "/test/test_2/test_2_2",
      "name": "test_2_2",
      "component": "@/views/test/test2/test_2_2.vue"
    }
  ]
}

修改loginApi.js
import request from '@/utils/request'

export function getCaptchaImage()  {
    return request({
        url: 'captchaImage',
    })
}

export function login(data)  {
    return request({
        method:'post',
        url: 'user/login',
        data
    })
}

export function getMenus()  { // 获取菜单
    return request({
        method:'get',
        url: 'test/getMenus'
    })
}

export function getRoutes()  { // 获取路由
    return request({
        method:'get',
        url: 'test/getRoutes'
    })
}
修改request.js

因为需要添加请求头,才能访问获取菜单路由接口

import axios from 'axios'
import Messager from './messager';
import pinia from '@/store'
import useUser from '@/store/user'



const instance = axios.create({
    baseURL: 'http://127.0.0.1:8080/api',
    timeout: 10000
})

instance.interceptors.request.use((config)=>{
    // debugger
    let userStore = useUser()
    if(userStore.token) {
        console.log('userStore.token',userStore.token);
        config.headers['Authorization'] = userStore.token
    }
    return config;
})

instance.interceptors.response.use(response=>{
    if(response.data.errno == 0) {
        return Promise.resolve(response.data.data)
    } else {
        if(response.data.errno == 501) {
            Messager.error('请重新登录')
            window.location.href = '/login'
        } else {
            Messager.error(response.data.errmsg)
            return Promise.reject(new Error(response.data.errmsg))
        }
    }
})

export default instance
修改router/index.js

将原本配置的静态路由删掉,这部分路由由后端返回,并添加前置守卫逻辑

import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
import Messager from '@/utils/messager';

import useMenu from '@/store/menu'

import pinia from '@/store'
import useUser from '@/store/user'
const userStore = useUser(pinia) // 此处不能像在组件里使用userStore一样直接useUser()调用,而是要先引入pinia,再传入。参考: https://blog.csdn.net/qq_21473443/article/details/126405859
console.log('router->userStore',userStore);

const menuStore = useMenu(pinia)


// 路由信息
const routes = [
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    },
    {
        path: '/',
        name: 'layout',
        component: ()=>import('@/layout/index.vue'),
        children: []
    },
    {
        path:'/:pathMatch(.*)*',
        name: 'notFound',
        component: ()=>import('@/views/404/NotFound.vue')
    },
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

router.beforeEach((to,from,next)=>{
    nprogress.start()
    // debugger
    let token = userStore.token
    if(!token) {
        if(to.path == '/login') {
            next()
        } else {
            next('/login')
        }
    } else {
        if(!menuStore.routesMenusLoaded) {
            menuStore.loadRoutesMenus().then(res=>{
                next()
            }).catch(err=>{
                // 加载出错,跳回到登录页去
                userStore.clearUserInfo()
                next('/login')
            })
        } else {
            if(to.path == '/login') {
                Messager.warn('你已登录!')
                next('/home')
            } else {
                next()
            }
        }
    }

})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;
修改user.js
import { defineStore } from 'pinia'

import { login } from '@/api/loginApi'

function retrieveLocalToken() {
    console.log('read token'); 
    return localStorage.getItem('token') || '' 
}
function clearLocalToken() {
    return localStorage.clear('token')
}

export default defineStore('user',{
    state: () => {
        return {
            token: retrieveLocalToken() // 当刷新页面时, 这个会加载一次
        }
    },
    getters: {

    },
    actions: {
        doLogin(data) {
            return new Promise((resolve, reject) => {
                login(data).then(res=>{
                    this.token = res // 同样先存入到pinia中
                    localStorage.setItem('token', res)
                    resolve(data)
                }).catch(err=>{
                    reject(err)
                })
            })
        },
        clearUserInfo() {
            this.token = null
            clearLocalToken()
        }
    }
})
创建store/menu.js

创建menu.js用来存储后台返回的数据

import { defineStore } from 'pinia'
import {getMenus,getRoutes} from '@/api/loginApi'
import router from '@/router'

export default defineStore('menu', {
    state: ()=> {
        return {
            routesMenusLoaded: false, // 路由菜单是否已加载
            menus: [], // 菜单
            routes: [] // 路由
        }
    },
    getters: {

    },
    actions: {
       loadRoutesMenus() {
        return new Promise(async (resolve,reject)=>{
            try {
                let menus = await getMenus()
                let routes = await getRoutes()

                // 保存路由
                this.routes = routes

                // 保存菜单
                this.menus = menus
                
                // 动态加载路由
                routes.forEach(route=>{
                    router.addRoute(
                        'layout', 
                        {
                            path: route.path,
                            name: route.name,
                            component: ()=>import(route.component.replace('@',"../")) // 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
                        }
                    )
                })

                console.log(router.getRoutes(),'finished');

                resolve()
            
            } catch (err) {
                reject(err)
            }

        })
       }
    }
})

修改菜单栏组件Sider.vue

<template>
    <div class="sider" :style="{ 'width': isExpand ? '220px' : '80px', transition: 'all 0.28s' }">

        <div class="sider-top">
            <h3 v-if="isExpand" class="site-title">PSCOOL管理系统</h3>
            <i v-else class="iconfont icon-graphcool site-icon"></i>
        </div>
        <div class="sider-body">
            <el-scrollbar>

                <el-menu 
                    :collapse="isCollapse" 
                    router 
                    collapse-transition text-color="#eee" 
                    :default-openeds="['/sys']" 
                    :default-active="activeMenu"
                    background-color="#294256" class="menu-bar">

                    <TreeMenu v-for="menuData,index in menuList" :menu="menuData" :key="index"></TreeMenu>

                </el-menu>
            </el-scrollbar>
        </div>
    </div>
</template>

<script setup>
import TreeMenu from './TreeMenu.vue'
import { ref,reactive } from 'vue'
import useLayout from '@/store/layout'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useRouter,useRoute } from 'vue-router'
import useMenu from '@/store/menu'

const layoutStore = useLayout()
const { isExpand } = storeToRefs(layoutStore) // 需要使用storeToRefs, 才能保持响应式

const isCollapse = computed({
    get() {
        return !isExpand.value
    }
})
watch(isExpand, (newVal, oldVal) => {
    // console.log('监听到变化');
})

const activeMenu = ref('/home') // 需要监听到当前的路由, 让它高亮, 因为用户有可能手动地址栏输入
const router = useRouter()
const route = useRoute()

watch(()=>route.fullPath, (newVal,oldVal)=>{
    console.log('监听到当前的路由', newVal);
    activeMenu.value = newVal;
}, {immediate:true})

const menuStore = useMenu()
const menuList = computed({
    get() {
        return menuStore.menus
    }
})


</script>

<style lang="scss">
.sider {
    width: 220px;
    height: 100vh;
    background-color: #294256;
    position: relative;

    box-shadow: 2px 0 8px 0 rgba(0, 0, 0, 0.3);

    flex-shrink: 0;

    .sider-top {
        height: 50px;

        display: flex;
        align-items: center;
        justify-content: center;

        overflow: hidden;

        .site-title {
            white-space: nowrap;
            font-weight: bold;
            color: #fff;
        }

        .site-icon {
            font-size: 20px;
            color: #27ae60;
        }
    }

    .sider-body {
        position: absolute;
        top: 50px;
        left: 0;
        right: 0;
        bottom: 0;

        background-color: #294256;

        .menu-bar {
            .iconfont {
                margin-right: 10px;
                font-size: 1.4em;
            }
        
        }
    }

}

.el-menu {
    border-right: none; // 修复边缘白边
}

ul.el-menu--inline, .nested-sub-menu div  {
    background-color: #1f2d3d !important; // 二级菜单深暗色
}
</style>

创建TreeMenu.vue递归组件

<template>
    <template v-if="!menu.children && menu.menuType == 'C'">
        <el-menu-item :index="menu.url">
            <i :class="menu.icon"></i>
            <span>{{ menu.title }}</span>
        </el-menu-item>
    </template>
    <template v-if="menu.children && menu.menuType == 'M'">
        <el-sub-menu :index="menu.url" :class="{'nested-sub-menu': menu.parentId != 0}">
            <template #title>
                <i :class="menu.icon"></i>
                <span>{{ menu.title }}</span>
            </template>
            <TreeMenu v-for="childMenu,index in menu.children" :menu="childMenu" :key="index"></TreeMenu>
        </el-sub-menu>
    </template>
</template>

<script setup>
defineProps({
    menu: {
        type: Object
    }
})
</script>

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

在这里插入图片描述

解决地址栏刷新问题

上面犯了一个错误,我把404路由作为静态路由,直接给放到了router里面了,这样404的路由就排在了前面,它不是精确匹配,导致我刷新页面的时候,直接就跳404页面了,所以把404的路由改到获取完后端的全部路由数据之后

修改router/index.js
import { createRouter,createWebHistory} from "vue-router";
import nprogress from "@/utils/nprogress";
import Messager from '@/utils/messager';

import useMenu from '@/store/menu'

import pinia from '@/store'
import useUser from '@/store/user'
const userStore = useUser(pinia) // 此处不能像在组件里使用userStore一样直接useUser()调用,而是要先引入pinia,再传入。参考: https://blog.csdn.net/qq_21473443/article/details/126405859
console.log('router->userStore',userStore);

const menuStore = useMenu(pinia)


// 路由信息
const routes = [
    {
        path: "/login",
        name: "login",
        component:  () => import('@/views/login/index.vue'),
    },
    {
        path: '/',
        name: 'layout',
        component: ()=>import('@/layout/index.vue'),
        children: []
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes
});

function existRoutePath(path) {
    let routes = router.getRoutes()
    let routePathArr = []
    routes.forEach((route) => {
        routePathArr.push(route.path)
    })
    return routePathArr.indexOf(path)
}

router.beforeEach((to,from,next)=>{
    nprogress.start()
    // console.log(router.getRoutes(),existRoutePath(to.path),'router hasRoute-3',to);
    // debugger
    let token = userStore.token
    if(!token) {
        if(to.path == '/login') {
            next()
        } else {
            next('/login')
        }
    } else {
        if(!menuStore.routesMenusLoaded) {
            // console.log(router.getRoutes(),existRoutePath(to.name),'router hasRoute-1',to);
            menuStore.loadRoutesMenus().then(res=>{
                // console.log(router.getRoutes(),existRoutePath(to.name),'router hasRoute-2',to);
                next({...to})
            }).catch(err=>{
                // 加载出错,跳回到登录页去
                userStore.clearUserInfo()
                next('/login')
            })
        } else {
            if(to.path == '/login') {
                Messager.warn('你已登录!')
                next('/home')
            } else {
                // console.log(router.getRoutes(),existRoutePath(to.name),'router hasRoute-4');
                next()
            }
        }
    }

})

router.afterEach((to,from,next)=>{
    nprogress.done()
})

// 导出路由
export default router;
修改menu.js
import { defineStore } from 'pinia'
import {getMenus,getRoutes} from '@/api/loginApi'
import router from '@/router'

export default defineStore('menu', {
    state: ()=> {
        return {
            routesMenusLoaded: false, // 路由菜单是否已加载
            menus: [], // 菜单
            routes: [] // 路由
        }
    },
    getters: {

    },
    actions: {
       loadRoutesMenus() {
        return new Promise(async (resolve,reject)=>{
            try {
                let menus = await getMenus()
                let routes = await getRoutes()

                // 保存路由
                this.routes = routes

                // 保存菜单
                this.menus = menus
                
                // 动态加载路由
                routes.forEach(route=>{
                    router.addRoute(
                        'layout', 
                        {
                            path: route.path,
                            name: route.name,
                            component: ()=>import(route.component.replace('@',"../")) // 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
                        }
                    )
                })

                router.addRoute({
                    path:'/:pathMatch(.*)*',
                    name: 'notFound',
                    component: ()=>import('../views/404/NotFound.vue')
                })
                

                console.log(router.getRoutes(),'加载路由 finished');
                this.routesMenusLoaded = true

                resolve()
            
            } catch (err) {
                reject(err)
            }

        })
       }
    }
})

12.全屏功能

安装screenfull

npm i screenfull -S

使用screenfull

<template>
	<div class="fullscreen mlr8" @click="toggleFullScreen">
	     <i :class="['iconfont', 'pointer', { 'icon-quanping_o': !isFullScreen, 'icon-quxiaoquanping_o': isFullScreen }]"></i>
	 </div>
</template>

<script>
	import { ref} from 'vue'
	const isFullScreen = ref(false)
	function toggleFullScreen() {
	    screenfull.toggle()
	    isFullScreen.value = !isFullScreen.value
	} 
</script>

13. 面包屑

我们需要展示当前路由的菜单的面包屑,先约定下数据,路由的name唯一且对应到菜单里的name且唯一,这样当我们切换到某一个路由的时候,就可以根据name到菜单里面递归的找到它所对应的所有父级。

数据

{
  "errno": 0,
  "errmsg": "成功",
  "data": [
    {
      "path": "/home",
      "name": "home",
      "component": "@/views/Home.vue"
    },
    {
      "path": "/sys/user",
      "name": "user",
      "component": "@/views/sys/user.vue"
    },
    {
      "path": "/sys/role",
      "name": "role",
      "component": "@/views/sys/role.vue"
    },
    {
      "path": "/sys/menu",
      "name": "menu",
      "component": "@/views/sys/menu.vue"
    },
    {
      "path": "/test/test_1",
      "name": "test_1",
      "component": "@/views/test/test_1.vue"
    },
    {
      "path": "/test/test_2/test_2_1",
      "name": "test_2_1",
      "component": "@/views/test/test2/test_2_1.vue"
    },
    {
      "path": "/test/test_2/test_2_2",
      "name": "test_2_2",
      "component": "@/views/test/test2/test_2_2.vue"
    }
  ]
}

{
  "errno": 0,
  "errmsg": "成功",
  "data": [
    {
      "id": 1,
      "parentId": 0,
      "name": "home",
      "title":"主页",
      "icon":"iconfont icon-home-line",
      "url":"/home",
      "menuType": "C",
      "component":"@/views/Home.vue"
    },
    {
      "id": 2,
      "parentId": 0,
      "name": "sys",
      "title":"系统设置",
      "icon":"iconfont icon-shezhi",
      "url":"/sys",
      "menuType": "M",
      "component":"",
      "children": [
        {
          "id": 3,
          "parentId": 2,
          "name": "user",
          "title":"用户管理",
          "icon":"iconfont icon-yonghuguanli",
          "url":"/sys/user",
          "menuType": "C",
          "component":"@/views/sys/user.vue"
        },
        {
          "id": 4,
          "parentId": 2,
          "name": "role",
          "title":"角色管理",
          "icon":"iconfont icon-jiaoseguanli",
          "url":"/sys/role",
          "menuType":"C",
          "component":"@/views/sys/role.vue"
        },
        {
          "id": 5,
          "parentId": 2,
          "name": "menu",
          "title":"菜单管理",
          "icon":"iconfont icon-icon_caidanguanli",
          "url":"/sys/menu",
          "menuType":"C",
          "component":"@/views/sys/menu.vue"
        }
      ]
    },
    {
      "id": 6,
      "parentId": 0,
      "name": "test",
      "title":"多级菜单",
      "icon":"iconfont icon-graphcool",
      "url":"/test",
      "component":"",
      "menuType":"M",
      "children": [
        {
          "id": 7,
          "parentId": 6,
          "name": "test_1",
          "title":"test_1",
          "icon":"iconfont icon-graphcool",
          "url":"/test/test_1",
          "menuType":"C",
          "component":"@/views/test/test_1.vue"
        },
        {
          "id": 8,
          "parentId": 2,
          "name": "test_2",
          "title":"test_2",
          "icon":"iconfont icon-graphcool",
          "url":"/test/test_2",
          "menuType":"M",
          "component":"",
          "children":[
            {
              "id": 9,
              "parentId": 8,
              "name": "test_2_1",
              "title":"test_2_1",
              "icon":"iconfont icon-graphcool",
              "menuType":"C",
              "url":"/test/test_2/test_2_1",
              "component":"@/views/test/test_2_1.vue"
            },
            {
              "id": 10,
              "parentId": 8,
              "name": "test_2_2",
              "title":"test_2_2",
              "icon":"iconfont icon-graphcool",
              "menuType":"C",
              "url":"/test/test_2/test_2_2",
              "component":"@/views/test/test_2_2.vue"
            }
          ]
        }

      ]
    }
  ]
}

修改menus.js

根据后台返回的菜单,递归出所有路由对应的带层级的菜单标题,放入路由的meta中

import { defineStore } from 'pinia'
import {getMenus,getRoutes} from '@/api/loginApi'
import router from '@/router'
import { dropdownMenuProps } from 'element-plus'

function generateNameMap(menus) {
    const nameMap = {}
    menus.forEach(menu => {
        handleMenu(menu,nameMap,[])
    })
    return nameMap
}

function handleMenu(menu,nameMap,titleArr) {
    titleArr.push(menu.title)
    nameMap[menu.name] = JSON.parse(JSON.stringify(titleArr))
    if(menu.children && menu.children.length > 0) {
        menu.children.forEach(menu => {
            let newTitleArr = JSON.parse(JSON.stringify(titleArr))
            handleMenu(menu,nameMap,newTitleArr)
        })
    }
}
    
export default defineStore('menu', {
    state: ()=> {
        return {
            routesMenusLoaded: false, // 路由菜单是否已加载
            menus: [], // 菜单
            routes: [] // 路由
        }
    },
    getters: {

    },
    actions: {
       loadRoutesMenus() {
        return new Promise(async (resolve,reject)=>{
            try {
                let menus = await getMenus()
                let routes = await getRoutes()

                // 保存路由
                this.routes = routes

                // 保存菜单
                this.menus = menus
                // debugger
                const nameMap = generateNameMap(menus)
                
                // 动态加载路由
                routes.forEach(route=>{
                    router.addRoute(
                        'layout', 
                        {
                            path: route.path,
                            name: route.name,
                            meta: {
                                titleArr: nameMap[route.name]
                            },
                            // 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
                            component: ()=>import(route.component.replace('@',"../")) 
                        }
                    )
                })

                router.addRoute({
                    path:'/:pathMatch(.*)*',
                    name: 'notFound',
                    component: ()=>import('../views/404/NotFound.vue')
                })
                

                console.log(router.getRoutes(),'加载路由 finished');
                this.routesMenusLoaded = true

                resolve()
            
            } catch (err) {
                reject(err)
            }

        })
       },

    }
})

修改Breadcrumb.vue

监听路由变化,从路由的meta中获取缓存的面包屑数据

<template>
    <el-breadcrumb separator="/" style="color: #303133;display: flex;white-space: nowrap;">
        <el-breadcrumb-item v-for="(title,index) in titleArr" :key="index">{{ title }}</el-breadcrumb-item>
    </el-breadcrumb>
</template>

<script setup>
    import { ref,reactive,watch } from 'vue'
    import { useRoute } from 'vue-router'

    const route = useRoute()
    const titleArr = ref([])

    watch(()=>route, (newRoute,oldRoute)=>{
        console.log('路由更新了',newRoute);
        titleArr.value = newRoute.meta.titleArr
    },{immediate: true,deep:true})
   

</script>

<style lang="scss">

</style>

14. tagsView

这一步主要实现tagsView功能,tagsView中记录用户访问过的菜单,并且能够根据需要关闭它,但是主页这个tag要一直保留。
我们把tag存放在pinia里面,tagsView组件通过计算属性引用pinia里面的tags,通过监听事件触发方法调用pinia里的方法
里面有个坑:点击关闭按钮的时候,需要阻止事件冒泡,否则,不仅会触发i这个icon的关闭的事件,又会触发div的点击事件,使用@click.stop去绑定

TagsView.vue

<template>
    <div class="main-header-tags-wrapper">
        <el-scrollbar>
            <div class="main-header-tags" id="main-header-tags">
                <div :class="['tag-item',{'active':tag.isActive}]" @click="selectSpecifiedTag(tag)" v-for="(tag,index) in tags" :key="index">
                    <span>{{ tag.title }}</span>
                    <i class="close-ico iconfont icon-guanbi" v-show="tag.name != 'home'" @click.stop="closeTag(tag)"></i>
                </div>
            </div>
        </el-scrollbar>
    </div>
</template>

<script setup>
    import useTagsView from '@/store/tagsView'
    import { computed, watch } from 'vue'
    import { useRoute,useRouter } from 'vue-router'
    const tagsViewStore = useTagsView()

    const tags = computed({
        get() {
            return tagsViewStore.tags
        }
    })

    const route = useRoute()
    const router = useRouter()
    watch(()=>route, (newRoute,oldRoute)=>{
        tagsViewStore.doOnrouteChange(newRoute)
    },{immediate:true,deep:true})
    
    function selectSpecifiedTag(tag) {
        debugger
         tagsViewStore.selectSpecifiedTag(tag)
         router.push({name:tag.name})
    }

    function closeTag(tag) {
        // 关闭的是不是当前激活的tag, 如果是当前激活的tag的话,就选择最后一个tag;如果不是当前激活的tag的话,就关掉它就行了
        let isCurrTagActiveClose = tag.isActive
        tagsViewStore.closeSpecifiedTag(tag)
        if(isCurrTagActiveClose) {
            // 选择最后面的tag
            debugger
            console.log('选择最后面的tag', tagsViewStore.tags[tagsViewStore.tags.length - 1]);
            selectSpecifiedTag(tagsViewStore.tags[tagsViewStore.tags.length - 1])
        }
        
    }

    
    
</script>

<style lang="scss">
.main-header-tags-wrapper {

padding: 0 10px;


.main-header-tags {
    height: 32px;

    display: flex;
    align-items: center;

    .tag-item {
        height: 26px;
        padding: 0 20px;
        margin-right: 8px;
        font-size: 13px;
        cursor: pointer;

        color: #495060;

        border: 1px solid #ccc;

        background-color: #fff;

        flex-shrink: 0;

        display: flex;
        align-items: center;
        justify-content: center;

        position: relative;

        i.close-ico {
            font-size: 12px;
            position: absolute;
            right: 2px;
            top: 4.5px;
            transform: scale(0.6);
            cursor: pointer;
            padding: 3px;
            border-radius: 50%;
            &:hover {
                background: #b4bccc;
            }
        }

        &.active {
            background-color: #409eff;
            border: #409eff;
            color: #fff;
            &::before {
                content: '';
                position: absolute;
                width: 6px;
                height: 6px;
                background-color: #fff;
                border-radius: 50%;
                left: 8px;
                top: 10.5px;
            }
        }
    }
}
}
</style>

TagsView.js

import { defineStore } from 'pinia'

export default defineStore('tagsView', {
    state: ()=> {
        return {
            tags: [
                {
                    title: '主页',
                    name: 'home',
                    path: '/home',
                    isActive: false
                }
            ],
        }
    },
    getters: {

    },
    actions: {
        doOnrouteChange(route) {
            debugger
            console.log('doOnrouteChange->新路由', route.name);
            let currRouteName = route.name
            let tagNameArr = []
            let flag = false
            this.tags.forEach(tag=>{
                tag.isActive = false
                if(tag.name == currRouteName) {
                    flag = true
                    tag.isActive = true
                }
            })       
            if(!flag) {
                console.log('原先没有这个路由,现在添加tag', route.name);
                this.tags.push({
                    title: route.meta.title,
                    name: route.name,
                    path: route.path,
                    isActive: true
                })
            }     
        },
        closeSpecifiedTag(tag){
            debugger
            let index = -1;
            for(let i=0;i<this.tags.length;i++) {
                if(this.tags[i].name === tag.name) {
                    index = i
                    break
                }
            }
            if(index > -1) {
                this.tags.splice(index,1)
            }
        },
        selectSpecifiedTag(tag) {
            debugger
            this.tags.forEach(t=>{
                t.isActive = false
                if(t.name == tag.name) {
                    t.isActive = true
                }
            })  
        }
    }
})

在这里插入图片描述

15. vue指令控制权限按钮显示

通过vue的directive指令方式,当用户拥有指定的权限时,才显示按钮

后台返回权限数据

{
  "errno": 0,
  "errmsg": "成功",
  "data": {
    "perms": [
      "user:list",
      "user:add",
      "user:remove",
      "role:list",
      "role:add",
      "role:remove"
    ]
  }
}

创建指令文件perms.js

import useMenu from '@/store/menu'
import { toRaw } from '@vue/reactivity'


export default {
    hasPerms: {
        mounted(el,binding) {
            const menuStore = useMenu()
            let perms1 = menuStore.perms

            console.log(el,binding,perms1);

            let perms2 = toRaw(perms1)
            let perms3 = JSON.parse(JSON.stringify(perms1))
            console.log(perms2.perms);
            console.log(perms3.perms);

            // 有任一指定的权限, 即可显示指定的dom, 否则移除
            if(!perms2.perms.some(p=>binding.value.includes(p))) {
                el.parentNode.removeChild(el)
            }
        },
    }
}

main.js中注册该指令

import { createApp } from 'vue'
import './style.css'
import '@/assets/iconfont/iconfont.css'
import App from './App.vue'

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

import * as ElementPlusIconsVue from '@element-plus/icons-vue'

import Messager from '@/utils/messager'


import router from '@/router'
import pinia from '@/store'

import perm from '@/directive/perm'


const app = createApp(App)

app.config.globalProperties.Messager = Messager

app.use(pinia)
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
}

// 注册指令
for(let key in perm) {
    app.directive(key, perm[key])
}

app.mount('#app')

loginApi.js中添加接口

// ...省略
export function getPerms()  {
    return request({
        method:'get',
        url: 'test/getPerms'
    })
}

修改store/menu.js

把获取权限的部分加进去

import { defineStore } from 'pinia'
import {getMenus,getRoutes,getPerms} from '@/api/loginApi'
import router from '@/router'
import { dropdownMenuProps } from 'element-plus'

function generateNameMap(menus) {
    const nameMap = {}
    menus.forEach(menu => {
        handleMenu(menu,nameMap,[])
    })
    return nameMap
}

function handleMenu(menu,nameMap,titleArr) {
    titleArr.push(menu.title)
    nameMap[menu.name] = JSON.parse(JSON.stringify(titleArr))
    if(menu.children && menu.children.length > 0) {
        menu.children.forEach(menu => {
            let newTitleArr = JSON.parse(JSON.stringify(titleArr))
            handleMenu(menu,nameMap,newTitleArr)
        })
    }
}
    
export default defineStore('menu', {
    state: ()=> {
        return {
            routesMenusLoaded: false, // 路由菜单是否已加载
            menus: [], // 菜单
            routes: [], // 路由,
            perms: [], // 权限
        }
    },
    getters: {

    },
    actions: {
       loadRoutesMenus() {
        return new Promise(async (resolve,reject)=>{
            try {
                let menus = await getMenus()
                let routes = await getRoutes()
                let perms = await getPerms()

                // 保存路由
                this.routes = routes

                // 保存菜单
                this.menus = menus

                // 保存权限
                this.perms = perms
                
                // debugger
                const nameMap = generateNameMap(menus)
                
                // 动态加载路由
                routes.forEach(route=>{
                    router.addRoute(
                        'layout', 
                        {
                            path: route.path,
                            name: route.name,
                            meta: {
                                titleArr: nameMap[route.name],
                                title: nameMap[route.name][nameMap[route.name].length-1]
                            },
                            // 好像直接以@开头,会报错,所以这干脆替换成相对路径吧
                            component: ()=>import(route.component.replace('@',"../")) 
                        }
                    )
                })

                router.addRoute({
                    path:'/:pathMatch(.*)*',
                    name: 'notFound',
                    component: ()=>import('../views/404/NotFound.vue')
                })
                

                console.log(router.getRoutes(),'加载路由 finished');
                this.routesMenusLoaded = true

                resolve()
            
            } catch (err) {
                reject(err)
            }

        })
       },

    }
})

User.vue中使用

<template>
    用户管理
    <el-button type="danger" v-hasPerms="['user:list']">查看</el-button>
    <el-button type="primary" v-hasPerms="['user:add']">添加</el-button>
    <el-button type="primary" v-hasPerms="['user:update']">修改</el-button>
    <el-button type="primary" v-hasPerms="['user:remove']">删除</el-button>
</template>

<script setup>

</script>

<style lang="scss">

</style>

如下效果
在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/2342.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C++类和对象(上篇)

目录 1.类的定义 2.类的访问限定符及封装 2.1类的访问限定符 2.2封装 3.类的作用域 4.类的实例化 5.类的大小 6.this 指针 1.类的定义 class className {// 类体&#xff1a;由成员函数和成员变量组成}; // 一定要注意后面的分号 class为定义类的关键字&#xff0c;Clas…

Golang每日一练(leetDay0012)

目录 34. 查找元素首末位置 Find-first-and-last-position-of-element-in-sorted-array &#x1f31f;&#x1f31f; 35. 搜索插入位置 Search Insert Position &#x1f31f; 36. 有效的数独 Valid Sudoku &#x1f31f;&#x1f31f; &#x1f31f; 每日一练刷题专栏 …

VsCode SSH远程连接服务器【内网穿透公网连接】

文章目录1.前言2.VS code的安装和设置2.1 VS code的下载安装2.2 OpenSSH的启用2.3 为VS code配置ssh2.4 局域网内测试VS code的ssh连接2.5 Cpolar下载安装3.Cpolar端口设置3.1 Cpolar云端设置3.2 Cpolar本地设置4.公网访问测试5.结语1.前言 记得笔者小时候看电视&#xff0c;看…

【安全与风险】密码学介绍

密码学介绍密码历史密码换位&#xff08;Transposition&#xff09;与置换&#xff08;Substitution&#xff09;替换密码&#xff08;Substiution Cipher&#xff09;凯撒密码 &#xff08;100BC 公元前100年&#xff09;移位密码破坏替换密码维吉尼亚密码现代密码学核心原理从…

TCP三次握手/四次挥手

TCP三次握手 任何基于TCP的应用&#xff0c;在发送数据之前&#xff0c;都需要由TCP进行“三次握手”建立连接示意图 第一次握手&#xff1a;客户端PC发送一个SYN位置1&#xff08;SYN1代表请求服务端建立连接&#xff09;的TCP报文发送给要建立TCP连接的Server&#xff0c;此…

23种设计模式

参考链接&#xff1a; 【狂神说Java】通俗易懂的23种设计模式教学&#xff08;停更&#xff09;_哔哩哔哩_bilibili 23种设计模式【狂神说】_狂神说设计模式_miss_you1213的博客-CSDN博客 1. 单例模式 参考链接&#xff1a; 【狂神说Java】单例模式-23种设计模式系列_哔哩哔哩…

Linux(网络基础---网络层)

文章目录0. 前言1. IP协议1-1 基本概念1-2 协议头格式2. 网段划分2-1 基本概念2.2 IP地址分五大类2-3 特殊的IP地址2-4 IP地址的数量限制2-5 私有IP地址和公网IP地址2-6 路由0. 前言 前面我们讲了&#xff0c;应用层、传输层&#xff1b;本章讲网络层。 应用层&#xff1a;我…

GPT-4是个编程高手,真服了!

上周给大家发了一个GPT-4教数学的介绍&#xff0c;很多人都被震撼了&#xff0c;感觉有可能在教育行业引发革命。它在编程领域表现如何&#xff1f;先不说能否替代程序员&#xff0c;这个还有待更多的测试和反馈&#xff0c;我想先试试它能不能像教数学那样教编程。我找了个Jav…

Docker的可视化界面工具

Docker的可视化界面工具1. Portainer1.1 Introduction1.1.1 Official1.2 Download And Deploy1.3 Dashboard1.3.1 Dashboard2. Shipyard2.1 Introduction2.1.1 Character2.1.2 Official2.2 Download And Deploy2.2.1 脚本下载镜像2.2.2 执行脚本2.2.2 查看下载的镜像2.3 Dashbo…

“工作三年,跳槽要求涨薪50%”,合理吗?

如果问在TI行业涨工资最快的方式是什么&#xff1f;回答最多的一定是&#xff1a;跳槽&#xff01;前段时间&#xff0c;知乎上这样一条帖子引发了不少IT圈子的朋友的讨论 &#xff0c;有网友提问 “程序员跳槽要求涨薪50%过分吗&#xff1f;”截图来源于知乎&#xff0c;如侵删…

【百面成神】多线程基础16问,你能坚持到第几问

前 言 &#x1f349; 作者简介&#xff1a;半旧518&#xff0c;长跑型选手&#xff0c;立志坚持写10年博客&#xff0c;专注于java后端 ☕专栏简介&#xff1a;纯手打总结面试题&#xff0c;自用备用 &#x1f330; 文章简介&#xff1a;多线程最基础、重要的16道面试题 文章目…

【百面成神】Redis基础11问,你能坚持到第几问

前 言 &#x1f349; 作者简介&#xff1a;半旧518&#xff0c;长跑型选手&#xff0c;立志坚持写10年博客&#xff0c;专注于java后端 ☕专栏简介&#xff1a;纯手打总结面试题&#xff0c;自用备用 &#x1f330; 文章简介&#xff1a;Redis最基础、重要的11道面试题 文章目录…

AI 未来已至,向量数据库站在新的节点上

“AI 的 iPhone 时刻已经到来。” 在刚刚结束的 NVIDIA GTC Keynote 中&#xff0c;这句话被 NVIDIA CEO 黄仁勋反复提及&#xff0c;长达 1 个多小时的分享中&#xff0c;生成式 AI 相关的内容占据了绝大部分比重。他表示&#xff0c;生成式 AI 的火热能力为企业带来了挑战&a…

2022/3/22 从CV方向角度 —快速解读Nvidia 2023GTC

GTC分享内容和个人看法 3月21号11点&#xff0c;Nvidia开启了GTC主题演讲&#xff0c;这些年英伟达加速库的发展和对AI的投入应用&#xff0c;不难看出掌握GPU加速计算技术的N家肯定是宣扬AI方向的产品和生产工具&#xff0c;下面我将简要汇总下演讲的内容&#xff0c;和从我自…

Java语言-----类与对象的秘密

目录 前言 一、类与对象的介绍 二、类的实例化 三.类与对象的使用方法 3.1对象的初始化 3.2内存显示图 四.this的使用方法 总结 &#x1f63d;个人主页&#xff1a; tq02的博客_CSDN博客-C语言,Java领域博主 &#x1f308;理想目标&#xff1a;努力学习&#xff0c;向Java进…

修改linux网卡配置文件的文件名

修改linux网卡配置文件的文件名 查看自己系统中网卡配置文件的文件名 #查看网卡的配置文件名&#xff0c;已经网络的状态 ip a查看系统是否可以使用ifconfig命令 #输入命令 ifconfig #出现以下图片表示ifconfig的命令可用。可能出现的错误&#xff1a;ifconfig command no foun…

第十七天 JavaScript、Vue详细总结

目录 JavaScript、Vue 1. JavaScript常用对象 1.1 Array对象 1.2 String对象 1.3 自定义对象 1.4 JSON 1.5 BOM 1.6 DOM 2. 事件监听 2.1 事件绑定 2.2 常见事件 2.3 案例 3. Vue 3.1 概述 3.2 快速入门 3.3 常用指令 3.4 生命周期 JavaScript、Vue 今日目标&…

为什么说网络安全是风口行业?是IT行业最后的红利?

前言 “没有网络安全就没有国家安全”。当前&#xff0c;网络安全已被提升到国家战略的高度&#xff0c;成为影响国家安全、社会稳定至关重要的因素之一。 网络安全行业特点 1、就业薪资非常高&#xff0c;涨薪快 2021年猎聘网发布网络安全行业就业薪资行业最高人均33.77万&…

2023年2月用户体验GX评测:国有行及股份行持续领跑,农商行农信社积极探索用户体验提升

易观&#xff1a;2023年2月易观千帆用户体验GX评测显示&#xff0c;国有行及股份制银行继续领跑手机银行用户体验&#xff0c;平安口袋银行、中国工商银行、招商银行稳居AAAAA级&#xff1b;城商行、农商行、农信社重视用户体验&#xff0c;银行下一步重点依然是狠抓用户体验建…

【java基础】Stream流的各种操作

文章目录基本介绍流的创建流的各种常见操作forEach方法filter方法map方法peek方法flatMap方法limit和skip方法distinct方法sorted方法收集结果收集为数组&#xff08;toArray&#xff09;收集为集合&#xff08;collect&#xff09;收集为Map关于流的一些说明&#xff08;终结操…