vue3后台管理系统

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

TypeError: Failed to fetch dynamically imported module:

以下代码项目gitee地址

文章目录

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);
                /*
                解释参考:https://juejin.cn/post/6844903478880370701 的评论区
                router.addRoutes是同步方法,整体流程:
				1. 路由跳转,根据目标地址从router中提取route信息,由于此时还没addRouters,所以解析出来的route是个空的,不包含组件。
				2. 执行beforeEach钩子函数,然后内部会动态添加路由,但此时route已经生成了,不是说router.addRoutes后,这个route会自动更新,如果直接next(),最终渲染的就是空的。
				3. 调用next({ ...to, replace: true }),会abort刚刚的跳转,然后重新走一遍上述逻辑,这时从router中提取的route信息就包含组件了,之后就和正常逻辑一样了。
				主要原因就是生成route是在执行beforeEach钩子之前。*/
                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, params: tag.params, query:tag.query})
    }

    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,
                    query: route.query,
                    params: route.params,
                    fullPath: route.fullPath
                }
            })       
            if(!flag) {
                console.log('原先没有这个路由,现在添加tag', route.name);
                this.tags.push({
                    title: route.meta.title,
                    name: route.name,
                    path: route.path,
                    isActive: true,
                    query: route.query,
                    params: route.params,
                    fullPath: route.fullPath
                })
            }     
        },
        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>

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

16.添加过渡效果

面包屑和路由的切换过程,看上去特别的生硬,我们需要给它们添加过渡效果,就像下面这样
在这里插入图片描述

面包屑过渡效果

  • 下面的过渡效果代码,是直接拷贝的官网,因为是用v-for遍历出来的,所以要用transition-group。
  • 还要注意的是,元素绑定的key要必须唯一(不能使用索引当key哦),这是vue的要求,不然不会有动画效果的
<template>
    <el-breadcrumb separator="/" style="color: #303133;display: flex;white-space: nowrap;">
      <transition-group name="list">
        <el-breadcrumb-item v-for="title in titleArr" :key="title">{{ title }}</el-breadcrumb-item>
      </transition-group>
    </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">
/* breadcrumb transition */
.list-move, /* 对移动中的元素应用的过渡 */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 确保将离开的元素从布局流中删除
  以便能够正确地计算移动的动画。 */
.list-leave-active {
  position: absolute;
}
</style>

路由过渡效果

修改Main.vue,以下仅把修改代码粘贴出来,其它部分省略了

<template>
	<div class="main-header">
		...
	</div>
	<div class="main-body">
	    <router-view v-slot:="{Component,route}">
	        <transition name="slide-fade" mode="out-in">
	            <component :is="Component" :key="route.path"/>
	        </transition>
	    </router-view>
	</div>
</template>

<style lang="scss" scoped>

	/*
	  进入和离开动画可以使用不同
	  持续时间和速度曲线。
	*/
	.slide-fade-enter-active {
	  transition: all 0.5s ease-out;
	}
	
	.slide-fade-leave-active {
	  transition: all 0.5s cubic-bezier(1, 0.5, 0.8, 1);
	}
	
	.slide-fade-enter-from,
	.slide-fade-leave-to {
	  transform: translateX(20px);
	  opacity: 0;
	}

...
</style>

17. 改成使用import.meta.glob导入路由

参考:
vue3 + vite动态导入路由
import.meta.glob批量引入文件

修改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)

                //定义一个函数,引入所有views下.vue文件 
                const modules = import.meta.glob(`../views/**/*.vue`);
                console.log(modules,'modules');
                /* 
                ../views/404/NotFound.vue: () => import("/src/views/404/NotFound.vue")
                ../views/Home.vue: () => import("/src/views/Home.vue")
                ../views/login/index.vue: () => import("/src/views/login/index.vue?t=1679996129333")
                ../views/sys/Menu.vue: () => import("/src/views/sys/Menu.vue")
                ../views/sys/Role.vue: () => import("/src/views/sys/Role.vue")
                ../views/sys/User.vue: () => import("/src/views/sys/User.vue")
                ../views/test/test_1.vue: () => import("/src/views/test/test_1.vue")
                modules
                */
                
                // 动态加载路由
                routes.forEach(route=>{
                    // debugger
                    // console.log(`../views/${route.component.substring(8)}`);
                    // console.log(`../views/${route.component.substring(8)}`,modules[`../views/${route.component.substring(8)}`]);
                    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(/* @vite-ignore */route.component.replace('@',"../")) 
                            
                            component: modules[`../views/${route.component.substring(8)}`]
                            // @/views/Home.vue -> ../views/Home.vue
                        }
                    )
                })

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

        })
       },

    }
})

再次修改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)

                //定义一个函数,引入所有views下.vue文件 
                const modules = import.meta.glob(`@/views/**/*.vue`);
                console.log(modules,'modules');
                /* 
                /src/views/404/NotFound.vue: () => import("/src/views/404/NotFound.vue")
                /src/views/Home.vue: () => import("/src/views/Home.vue")
                /src/views/login/index.vue: () => import("/src/views/login/index.vue?t=1679996129333")
                /src/views/sys/Menu.vue: () => import("/src/views/sys/Menu.vue")
                /src/views/sys/Role.vue: () => import("/src/views/sys/Role.vue")
                /src/views/sys/User.vue: () => import("/src/views/sys/User.vue")
                /src/views/test/test_1.vue: () => import("/src/views/test/test_1.vue")
                modules
                */
                
                // 动态加载路由
                routes.forEach(route=>{
                    // debugger
                    console.log(route.component);
                    console.log(route.component, modules[route.component]);
                    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(/* @vite-ignore */route.component.replace('@',"../")) 
                            
                            // 改成使用import.meta.glob动态导入
                                                        component: modules[route.component.replace('@','/src')]

                        }
                    )
                })

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

        })
       },

    }
})

18. 添加页签缓存

参考:

官方文档-KeepAlive的api

若依#页签缓存

由于目前 keep-aliverouter-view 是强耦合的,而且查看文档和源码不难发现 keep-aliveinclude (opens new window)默认是优先匹配组件的 name ,所以在编写路由 router 和路由对应的 view component 的时候一定要确保 两者的 name 是完全一致的。(切记 name 命名时候尽量保证唯一性 切记不要和某些组件的命名重复了,不然会递归引用最后内存溢出等问题)

所以,我们要保证路由的name和组件的name名字一致才可以的哦!但是,我们的组件使用了setup语法糖,可以参考这个Vue3 setup 语法糖下如何定义组件名称,添加组件名字。这里我用的第一种方法:再弄个script标签,配置name选项。

修改User组件

<template>
    <div class="main-box">
       ...
    </div>
</template>

<script>
    <!-- 配置组件名称 -->
    export default {
        name: 'user'
    }
</script>

<script setup>
    ...
</script>

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

修改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">
                       
                    </div>
                    <Breadcrumb />
                </div>
                <div class="main-header-top-right">
             
                </div>
            </div>
            <TagsView />
        </div>
        
        <div class="main-body">
            
            <router-view v-slot:="{ Component, route }">
                
                <transition name="slide-fade" mode="out-in">
                    
                    <!-- 添加keepAlive组件, 实现页签缓存 -->
                    <keep-alive :include="activeTagNames" exclude="role">
                        <component :is="Component" :key="route.path" />
                    </keep-alive>
                    
                </transition>
                
            </router-view>
            
        </div>
        
    </div>
</template>

<script setup>
    
import { ref, reactive, onMounted, onUnmounted,computed } from 'vue' 
    
// 引入tagsViewStore
import useTagsView from '@/store/tagsView'


// 要缓存的组件的名字(在tagsView中页签显示名字的组件才需要缓存)
let activeTagNames = computed({
    get() {
        let tagsViewsStore = useTagsView()
        return tagsViewsStore.tags.map(tag=>tag.name)
    }
})

</script>

<style lang="scss" scoped>

</style>

实现效果

为了测试效果,特意把role给排除掉,可以看到role组件并没有缓存下来,user组件缓存了下来
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值