安全框架springSecurity+Jwt+Vue-1(vue环境搭建、动态路由、动态标签页)

一、安装vue环境,并新建Vue项目

①:安装node.js

官网(https://nodejs.org/zh-cn/)
2.安装完成之后检查下版本信息:

②:创建vue项目

1.接下来,我们安装vue的环境

yaml 复制代码
# 安装淘宝npm
npm install -g cnpm --registry=https://registry.npm.taobao.org
# vue-cli 安装依赖包
cnpm install --g vue-cli
# 打开vue的可视化管理工具界面
vue ui

2.创建spring_security_vue项目 运行vue ui

3. 会为我们打开一个http://localhost:8001/dashboard的页面:

4.我们将在这个页面完成我们的前端Vue项目的新建。然后切换到【创建】,注意创建的目录最好是和你运行vue ui同一级。这样方便管理和切换

5.然后点击按钮【在此创建新项目】下一步中,项目文件夹中输入项目名称"sping_security_vue"

6.点击下一步,选择【手动】,再点击下一步,如图点击按钮,勾选上路由Router、状态管理Vuex,去掉js的校验。


7.下一步中,也选上【Use history mode for router】,点击创建项目,然后弹窗中选择按钮【创建项目,不保存预设】,就进入项目创建啦

稍等片刻之后,项目就初始化完成了。上面的步骤中,我们创建了一个vue项目,并且安装了Router、Vuex。这样我们后面就可以直接使用。

Router : WebApp的链接路径管理系统,简单就是建立起url和页面之间的映射关系
Vuex: 一个专为 Vue.js 应用程序开发的状态管理模式,简单来说就是为了方便数据的操作而建立的一个临时" 前端数据库",用于各个组件间共享和检测数据变化。

ok,我们使用IDEA导入项目,看看创建好的项目长啥样子:

③:启动项目

1.然后我们在IDEA窗口的底部打开Terminal命令行窗口,输入yarn run serve
运行vue项目,我们就可以通过http://localhost:8080/打开我们的项目了。

2.效果如下,Hello Vue!

④:安装element-ui

接下来我们引入element-ui组件(https://element.eleme.cn),这样我们就可以获得好看的vue组件,开发好看的后台管理系统的界面啦。

1.命令安装

yaml 复制代码
# 安装element-ui
yarn add element-ui --save

2.然后我们打开项目src目录下的main.js,引入element-ui依赖。

js 复制代码
import Element from 'element-ui'
import "element-ui/lib/theme-chalk/index.css"
Vue.use(Element)

⑤: 安装axios、qs、mockjs

  • axios:一个基于 promise 的 HTTP 库,类ajax
  • qs:查询参数序列化和解析库
  • mockjs:为我们生成随机数据的工具库

1. 安装axios

接下来,我们来安装axios(http://www.axios-js.com/),axios是一个基于 promise 的 HTTP 库,这样我们进行前后端对接的时候,使用这个工具可以提高我们的开发效率。

1.安装命令

yaml 复制代码
 yarn add axios --save

2.在main.js中全局引入axios

yaml 复制代码
import axios from 'axios'
Vue.prototype.$axios = axios //

2.安装qs

我们安装一个qs,什么是qs?qs是一个流行的查询参数序列化和解析库。可以将一个普通的object序列化成一个查询字符串,或者反过来将一个查询字符串解析成一个object,帮助我们查询字符串解析和序列化字符串。

1.安装命令

js 复制代码
 yarn add qs --save

3.安装mockjs

因为后台我们现在还没有搭建,无法与前端完成数据交互,因此我们这里需要mock数据,因此我们引入mockjs(http://mockjs.com/),方便后续我们提供api返回数据

1.安装命令

js 复制代码
 yarn add mockjs --save-dev

2.然后我们在src目录下新建mock.js文件,用于编写随机数据的api,然后我们需要在main.js中引入这个文件

  • src/main.js
js 复制代码
require("./mock") //引入mock数据,关闭则注释该行

后面我们mackjs会自动为我们拦截ajax,并自动匹配路径返回数据!

二、页面路由

Router :WebApp的链接路径管理系统,简单就是建立起url和页面之间的映射关系

所以我们要打开页面然后开发页面,我们需要先配置路由,然后再开发,这样我们可以试试看到效果。项目中,src\router\index.js就是用来配置路由的。

1.我们在views文件夹下定义几个页面:

  • Login.vue(登录页面)
  • Index.vue(首页)

2.配置url与vue页面的映射关系src\router\index.js

js 复制代码
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from "@/views/Login";
import Index from "@/views/Index";

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'login',
    component: Login
  },
  {
    path: '/index',
    name: 'index',
    component: Index
  },
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

3.运行yarn run serve打开http://localhost:8082/login查看效果

三、登陆界面开发

一开始的时候为了页面风格的统一,我们采用了Element Ui的组件库,所以这里我们就直接去element的官网。所以先找到Loyout布局然后再弄表单,然后我们涉及到的后台交互有2个:

  • 获取登录验证码
  • 提交登录表单完成登录

因为后台系统我们暂时还没有开发,所以这里我们需要自己mock数据完成交互。前面我们已经引入了mockjs,所以我们到mock.js文件中开发我们的api。

①:登录交互过程

1.交互流程

1.我们梳理一下交互流程:

  1. 浏览器打开登录页面

  2. 动态加载登录验证码,因为这是前后端分离的项目,我们不再使用session进行交互,所以后端我打算禁用session,那么验证码的验证就是问题了,所以后端设计上我打算生成验证码同时生成一个随机码,随机码作为key,验证码为value保存到redis中,然后把随机码和验证码图片的Base64字符串码发送到前端

  3. 前端提交用户名、密码、验证码还有随机码

  4. 后台验证验证码是否匹配以及密码是否正确

    ok,这样我们就知道mock应该弄成什么样的api了。

2. mock.js定义需要的api

2.mock.js - 获取登录验证码

js 复制代码
// 引入mock
let Mock = require('mockjs');
// 获取Mock.random对象
// 参考:https://github.com/nuysoft/Mock/wiki/Mock.Random
let random = Mock.Random;
let Result = {
    code: 200,
    msg: '操作成功!',
    data: null
}
/**
 * Mock.mock( url, post/get , function(options));
 * url 表示需要拦截的 URL,
 * post/get 需要拦截的 Ajax 请求类型
 *
 * 用于生成响应数据的函数
 */

Mock.mock('/captcha', 'post', ()=>{
    Result.data = {
        randomCode: random.string(32), // 获取一个32位的随机字符串
        captchaImg: random.dataImage('120x40', 'p7n5w') // //生成验证码为11111的base64图片编码
    }
    return Result;
})

mock生成数据还算简单,一般都是利用Mock.Random对象来生成一些随机数据,具体的用法可以参考https://github.com/nuysoft/Mock/wiki/Mock.Random。然后Result是为了统一返回结果,因为后台设计的时候,前后端交互,一般都有固定的返回格式,所以就有了Result。

3.mock.js - 登录接口

javascript 复制代码
/*
    登录接口
 */

// 因为mock 不认识/login?username=xxx, 所以用了正则表达式
Mock.mock(RegExp('/login*'),'post',(config)=>{
    // 这里无法在header添加authorization,直接跳过
    console.log("mock----------------login")
    return Result
})

3.开发登录页面

1.Login.vue登录页面

html 复制代码
<template>
    <el-row type="flex" class="row-bg" justify="center">
        <el-col class="el-col">
            <h3 style="color: white; font-weight: bold; font-size: 21px; margin: 0 0 20px 0;padding: 0">Spring security安全框架</h3>
            <el-form :model="form" :rules="rules" ref="ruleForm" class="demo-ruleForm">
                <el-form-item prop="username" style="width: 18rem;">
                    <el-input prefix-icon="el-icon-user" placeholder="用户名" v-model="form.username"></el-input>
                </el-form-item>
                <el-form-item prop="password" style="width: 18rem;">
                    <el-input prefix-icon="el-icon-lock" show-password placeholder="密码" v-model="form.password"></el-input>
                </el-form-item>
                <el-form-item prop="code" style="width: 18rem;">
                    <el-input prefix-icon="el-icon-picture-outline" v-model="form.code" placeholder="验证码"
                              :show-password="true" style="width: 10.8rem; float: left;" maxlength="5"></el-input>
                    <el-image class="captchaImg" :src="captchaImg" style="width: 6.7rem; float: left;"></el-image>
                </el-form-item>
                <el-form-item>
                    <el-button type="primary" style="width: 18rem;" @click="submitForm('ruleForm')">登录</el-button>
                </el-form-item>
            </el-form>
        </el-col>
    </el-row>
</template>

<script>
export default {
    name: "Login",
    data() {
        return {
            form: {
                username: null, // 用户名
                password: null, // 密码
                code: null, // 验证码
                randomCode: null, // 随机码
            },
            captchaImg: null, //图片
            rules: {
                username: [
                    {required: true, message: '请输入用户名', trigger: 'blur'},
                ],
                password: [
                    {required: true, message: '请输入密码', trigger: 'blur'},
                    {min: 6, message: '密码长度至少 6 个字符', trigger: 'blur'}
                ],
                code: [
                    {required: true, message: '请输入验证码', trigger: 'blur'},
                    {min: 5, max: 5, message: '验证码长度为 5 个字符', trigger: 'blur'}
                ],
            }
        }
    },
    mounted() {
        this.getCaptchaImg();
    },
    methods: {
        // 获取验证码和随机码
        getCaptchaImg() {
            this.$axios.post('/captcha').then((res) => {
                if (res.data.code == 200){
                    this.form.randomCode = res.data.data.randomCode;
                    this.captchaImg = res.data.data.captchaImg;
                }else {
                    this.$message.error("验证码获取失败!")
                }
            })
        },

        // 登录
        toLogin() {
            this.$axios.post('/login', this.form).then((res) => {
                if (res.data.code == 200){
                    // todo 登录成功
                    const jwt = res.headers['authorization']
                    this.$store.commit('SET_TOKEN', jwt)
                    this.$router.push('/index')
                }else {
                    this.$message.error(res.data.msg)
                }
            })
        },


        submitForm(formName) {
            this.$refs[formName].validate((valid) => {
                if (valid) {
                    this.toLogin();
                } else {
                    console.log('error submit!!');
                    return false;
                }
            });
        },
    }
}
</script>

<style scoped>
.row-bg {
    background-image: url("/public/img/login_bk2.jpg");
    background-size: cover;
    background-repeat: no-repeat;
    /*background-color: #fafafa;*/
    height: 100vh;
    opacity: 0.9;
    filter: none;
}

.el-col {
    width: 22rem;
    margin: auto;
    /* 半透明黑色背景 */
    background-color: rgba(0, 0, 0, 0.30) !important;
    padding: 1rem 1.5rem 1rem 1.5rem;
    border-radius: 0.6rem;
    box-shadow: 0 0 10.8rem 0.2rem rgba(0, 0, 0, 0.1);
}

.demo-ruleForm {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
    margin-bottom: -10px;
}

.captchaImg {
    float: left;
    margin-left: 8px;
    border-radius: 4px;
}
</style>

2.效果

②:token的状态同步

再讲一下,submitForm方法中,提交表单之后做了几个动作,从Header中获取用户的authorization,也就是含有用户登录信息的jwt,然后提交到store中进行状态管理。

this.$store.commit("SET_TOKEN", jwt) 表示调用store中的SET_TOKEN方法,所以我们需要在store中编写方法

1.src/store/index.js

javascript 复制代码
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        token: null,
    },
    getters: {},
    mutations: {
        SET_TOKEN(state, token) {
            state.token = token;
            localStorage.setItem('token', token)
        }
    },
    actions: {},
    modules: {}
})

这样登录之后获取到的jwt就可以存储到应用的store以及localStorage中,方便使用直接从localStorage中获取即可! 这样用户登录成功之后就会跳转到/index页面this.$router.push("/index")

③:定义全局axios拦截器

这里有个问题,那么如果登录失败,我们是需要弹窗显示错误的,比如验证码错误,用户名或密码不正确等。不仅仅是这个登录接口,所有的接口调用都会有这个情况,所以我们想做个拦截器,对返回的结果进行分析,如果是异常就直接弹窗显示错误,这样我们就省得每个接口都写一遍了。

1.在src目录下创建一个文件axios.js(与main.js同级),定义axios的拦截:

javascript 复制代码
// 引入所需的库和模块
import axios from "axios";
import router from "@/router"; // 假设这是指向路由模块的路径
import Element from "element-ui";

// 设置所有 Axios 请求的基础 URL
// axios.defaults.baseURL = "https://localhost:19005";

// 创建一个具有自定义设置的 Axios 实例
let request = axios.create({
    timeout: 5000, // 设置请求的超时时间为5000毫秒
    headers: {
        'Content-Type': 'application/json;charset=utf-8' // 设置请求数据的内容类型为 JSON
    }
});

// 在发送请求之前拦截请求
request.interceptors.request.use(config => {
    // 使用本地存储中的令牌设置请求的 'Authorization' 头部
    config.headers['Authorization'] = localStorage.getItem('token');
    return config;
});

// 在处理响应之前拦截响应
request.interceptors.response.use(response => {
    // 从响应中提取数据
    let res = response.data;

    // 检查响应代码是否为200(成功)
    if (res.code === 200) {
        return response; // 如果成功,则返回响应
    } else {
        // 如果响应代码不是200,则使用 Element UI 显示错误消息
        Element.Message.error(res.msg ? res.msg : '系统异常');
        return Promise.reject(res.msg); // 使用错误消息拒绝 Promise
    }
}, error => {
    console.log('error', error);

    // 处理特定的错误情况
    if (error.code === 401) {
        router.push('/login'); // 如果错误代码是401(未经授权),则重定向到登录页面
    }

    console.log(error.message);

    // 使用 Element UI 显示错误消息,持续时间为3000毫秒
    Element.Message.error(error.message, { duration: 3000 });
    return Promise.reject(error.message); // 使用错误消息拒绝 Promise
});

// 将配置好的 Axios 实例导出,以在应用程序的其他部分中使用
export default request;

前置拦截,其实可以统一为所有需要权限的请求装配上header的token信息,后置拦截中,判断status.code和error.response.status,如果是401未登录没权限的就调到登录页面,其他的就直接弹窗显示错误。

2.再main.js中导入自己创建axios.js

javascript 复制代码
import axios from "@/axios";

Vue.prototype.$axios = axios

这样axios每次请求都会被前置拦截器和后置拦截器拦截了。

3.在mock.js中修改登录的接口

javascript 复制代码
/*
    登录接口
 */

// 因为mock 不认识/login?username=xxx, 所以用了正则表达式
Mock.mock(RegExp('/login*'),'post',(config)=>{
    // 这里无法在header添加authorization,直接跳过
    Result.code = 400;
    Result.msg = '验证码错误!';
    return Result
})

4.登录异常弹窗效果如下:

  • 我们发现登录时 确实有验证码错误的弹出 但是同时界面会出现一个遮罩层提示Uncaught runtime errors

  • 解决方法

5.打开vue.config.js

javascript 复制代码
    devServer:{
        // 解决页面弹出红色报错遮罩层
        client: {
            //将overlay设置为false即可
            overlay: false
        }
    }

6.重新测试登录 正常

四、后台管理界面开发

ok,登录界面我们已经开发完毕,并且我们已经能够进入管理系统的首页了,接下来我们就来开发首页的页面。

一般来说,管理系统的页面我们都是头部是一个简单的信息展示系统名称和登录用户信息,然后中间的左边是菜单导航栏,右边是内容,对应到elementui的组件中,我们可以找到这个Container 布局容器用于布局,方便快速搭建页面的基本结构。

而我们采用这个布局:

而这个页面,一般来说Header和Aside都是不会变化的,只有Main部分会跟着链接变化而变化,所以我们可以提炼公共部分出来,放在Home.vue中,然后Main部分放在Index.vue中,

那么问题来了,我们如何才能做到点击左边的Aside,然后局部刷新Main中的内容呢?在Vue中,我们可以通过嵌套路由(子路由)的形式。也就是我们需要重新定义路由,一级路由是Home.vue,Index.vue是作为Home.vue页面的子路由,然后Home.vue中我们通过来展示Index.vue的内容即可。

1.创建 src/views/Home.vue
2.在router中,我们这样修改:

js 复制代码
const routes = [
    {
        path: '/login',
        name: 'login',
        component: Login
    },
    {
        path: '/',
        name: 'home',
        redirect: '/index',
        component: Home,
        children: [{
            path: '/index',
            name: 'index',
            meta: {
                title: '首页'
            },
            component: Index
        }]
    },
]

可以看到原本的Index已经作为了Home的children,所以在链接到/index的时候我们会展示父级Home的内容,然后再显示Index内容。

3.src/views/Home.vue

html 复制代码
<template>
    <div id="home">
        <el-container>
            <el-aside width="200px">菜单栏</el-aside>
            <el-container>
                <el-header>
                    <strong>Spring Security安全框架</strong>
                    <div class="header-right">
                        <el-avatar size="medium" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"></el-avatar>
                        <el-dropdown>
                            <span class="el-dropdown-link">
                                Admin<i class="el-icon-arrow-down el-icon--right"></i>
                            </span>
                            <el-dropdown-menu slot="dropdown">
                                <el-dropdown-item divided>个人中心</el-dropdown-item>
                                <el-dropdown-item divided>退出</el-dropdown-item>
                            </el-dropdown-menu>
                        </el-dropdown>
                        <el-link href="https://mp.csdn.net/mp_blog/manage/article?spm=1011.2124.3001.5298">CSDN笔记</el-link>
                        <el-link href="https://gitee.com/">Gitee仓库</el-link>
                    </div>
                </el-header>
                <el-main>
                    <router-view/>
                </el-main>
            </el-container>
        </el-container>
    </div>
</template>

<script>
export default {
    name: "Home"
}
</script>

<style lang="less" scoped>
.el-container {
    margin: 0;
    padding: 0;
    height: 100vh;

    .header-right {
        width: 260px;
        float: right;
        display: flex;
        justify-content: space-around;
        align-items: center;
        font-weight: bold;
    }
}

.el-header, .el-footer {
    background-color: #B3C0D1;
    color: #333;
    text-align: center;
    line-height: 60px;
}

.el-aside {
    background-color: #D3DCE6;
    color: #333;
    text-align: center;
    line-height: 200px;
}

.el-main {
    background-color: #E9EEF3;
    color: #333;
    text-align: center;
    line-height: 160px;
}

body > .el-container {
    margin-bottom: 40px;
}

.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
    line-height: 260px;
}

.el-container:nth-child(7) .el-aside {
    line-height: 320px;
}

.el-dropdown-link {
    cursor: pointer;
    color: #409EFF;
}
.el-icon-arrow-down {
    font-size: 12px;
}
</style>

4.src/views/Index.vue

html 复制代码
<template>
    <div>
        <el-carousel :interval="4000" type="card" indicator-position="outside">
            <el-carousel-item v-for="url in urls" :key="url">
                <el-image :src="url"></el-image>
            </el-carousel-item>
        </el-carousel>
    </div>
</template>

<script>
export default {
    name: "Index",
    data() {
        return {
            urls: [
                'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
                'https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg',
                'https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg',
                'https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg',
                'https://fuss10.elemecdn.com/d/e6/c4d93a3805b3ce3f323f7974e6f78jpeg.jpeg',
                'https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg',
                'https://fuss10.elemecdn.com/2/11/6535bcfb26e4c79b48ddde44f4b6fjpeg.jpeg'
            ]
        }
    }
}
</script>

<style lang="less" scoped>
.el-carousel__item h3 {
    color: #475669;
    font-size: 14px;
    opacity: 0.75;
    line-height: 200px;
    margin: 0;
}

.el-carousel__item:nth-child(2n) {
    background-color: #99a9bf;
}

.el-carousel__item:nth-child(2n+1) {
    background-color: #d3dce6;
}
</style>

5.总体下来效果如下:

有点感觉了,然后左边的菜单栏我们也弄下,我们找到NavMenu 导航菜单组件,然后加到Home.vue 中,因为考虑到后面我们需要做动态菜单,所以我想单独这个页面出来,因此我新建了个SideMenu.vue

6.SideMenu.vue

javascript 复制代码
<template>
    <el-menu
        class="el-menu-vertical-demo"
        background-color="#545c64"
        text-color="#fff"
        active-text-color="#ffd04b"
    >
        <router-link to="/index">
            <el-menu-item index="Index">
                <template slot="title">
                    <i class="el-icon-s-home"></i>
                    <span slot="title">首页</span>
                </template>
            </el-menu-item>
        </router-link>
        <el-submenu index="1">
            <template slot="title">
                <i class="el-icon-s-operation"></i>
                <span>系统管理</span>
            </template>
            <el-menu-item index="1-1">
                <template slot="title">
                    <i class="el-icon-s-custom"></i>
                    <span slot="title">用户管理</span>
                </template>
            </el-menu-item>
            <el-menu-item index="1-2">
                <template slot="title">
                    <i class="el-icon-rank"></i>
                    <span slot="title">角色管理</span>
                </template>
            </el-menu-item>
            <el-menu-item index="1-3">
                <template slot="title">
                    <i class="el-icon-menu"></i>
                    <span slot="title">菜单管理</span>
                </template>
            </el-menu-item>
        </el-submenu>
        <el-submenu index="2">
            <template slot="title">
                <i class="el-icon-s-tools"></i>
                <span>系统工具</span>
            </template>
            <el-menu-item index="2-2">
                <template slot="title">
                    <i class="el-icon-s-order"></i>
                    <span slot="title">数字字典</span>
                </template>
            </el-menu-item>
        </el-submenu>
    </el-menu>
</template>

<script>
export default {
    name: "SideMenu"
}
</script>

<style lang="less" scoped>
.el-menu-vertical-demo{
    height: 100%;
}
</style>

SideMenu.vue作为一个组件添加到Home.vue中,我们首选需要导入,然后声明compoents,然后才能使用标签

7.在Home.vue中代码如下

html 复制代码
<template>
    <div id="home">
        <el-container>
            <el-aside width="200px">
                <SideMenu></SideMenu>
            </el-aside>
            <el-container>
            ....
            </el-container>
        </el-container>
    </div>
</template>

<script>
import SideMenu from "@/views/SideMenu";
export default {
    name: "Home",
    components: {SideMenu}
}
</script>

8.最后效果如下:

我们先来新建几个页面,先在views下新建文件夹sys,然后再新建vue页面,具体看下面,这样我们就能把链接和页面可以连接起来。

  • src\views\sys
    • Dict.vue 数字字典
    • Menu.vue 菜单管理
    • Role.vue 角色管理
    • User.vue 用户管理

虽然建立了页面,但是因为我们没有在router中注册链接与组件的关系,所以我们现在打开链接还是打开不了页面的。下面我们就要动态联系起来。

五、用户登录信息展示

管理界面的右上角的用户信息现在是写死的,因为我们现在已经登录成功,所以我们可以通过接口去请求获取到当前的用户信息了,这样我们就可以动态显示用户的信息,这个接口比较简单,然后退出登录的链接也一起完成,就请求接口同时把浏览器中的缓存删除就退出了哈。

1.src\views\Home.vue

html 复制代码
<template>
    <div id="home">
        <el-container>
            <el-aside width="200px">
                <SideMenu></SideMenu>
            </el-aside>
            <el-container>
                <el-header>
                    <strong>Spring Security安全框架</strong>
                    <div class="header-right">
                        <el-avatar size="medium" :src="form.avatar"></el-avatar>
                        <el-dropdown>
                            <span class="el-dropdown-link">
                                {{ form.username }}<i class="el-icon-arrow-down el-icon--right"></i>
                            </span>
                            <el-dropdown-menu slot="dropdown">
                                <el-dropdown-item divided>个人中心</el-dropdown-item>
                                <el-dropdown-item @click.native="logout" divided>退出</el-dropdown-item>
                            </el-dropdown-menu>
                        </el-dropdown>
                        <el-link href="https://mp.csdn.net/mp_blog/manage/article?spm=1011.2124.3001.5298">CSDN笔记
                        </el-link>
                        <el-link href="https://gitee.com/">Gitee仓库</el-link>
                    </div>
                </el-header>
                <el-main>
                    <router-view/>
                </el-main>
            </el-container>
        </el-container>
    </div>
</template>

<script>
import SideMenu from "@/views/SideMenu";
import {getUserInfo, logout} from "@/api/login";

export default {
    name: "Home",
    components: {SideMenu},
    data() {
        return {
            form: {
                id: null,
                username: null, // 用户名
                avatar: null, // 头像
            }
        }
    },
    mounted() {
        this.getUserInfo();
    },
    methods: {
        getUserInfo(){
            getUserInfo().then(res =>{
                Object.assign(this.form, res.data.data);
            })
        },
        logout(){
            logout().then(res =>{
                console.log(res.data.data)
                this.$store.commit('RESET_STATE')
                this.$router.push('/login')
            })
        }
    },
}
</script>

<style lang="less" scoped>
.el-container {
    margin: 0;
    padding: 0;
    height: 100vh;

    .header-right {
        width: 260px;
        float: right;
        display: flex;
        justify-content: space-around;
        align-items: center;
        font-weight: bold;
    }
}

.el-header, .el-footer {
    background-color: #B3C0D1;
    color: #333;
    text-align: center;
    line-height: 60px;
}

.el-aside {
    background-color: #D3DCE6;
    color: #333;
    text-align: center;
    line-height: 200px;
}

.el-main {
    background-color: #E9EEF3;
    color: #333;
    text-align: center;
    line-height: 160px;
}

body > .el-container {
    margin-bottom: 40px;
}

.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
    line-height: 260px;
}

.el-container:nth-child(7) .el-aside {
    line-height: 320px;
}

.el-dropdown-link {
    cursor: pointer;
    color: #409EFF;
}

.el-icon-arrow-down {
    font-size: 12px;
}

</style>

2.由于我们将请求接口提取到js中了 所以在src下创建一个api文件夹

  • login.js
javascript 复制代码
import axios from "@/axios";


// 获取验证码和随机码
export function getCaptchaImg(data) {
    return axios({
        url: '/captcha',
        method: 'post',
        data: data
    })
}

// 登录
export function toLogin(data) {
    return axios({
        url: '/login',
        method: 'post',
        data: data
    })
}

// 获取用户信息
export function getUserInfo(data) {
    return axios({
        url: '/userInfo',
        method: 'get',
        params: data
    })
}

// 登出
export function logout(data) {
    return axios({
        url: '/logout',
        method: 'post',
        data: data
    })
}

3.src/store/index.js

javascript 复制代码
        RESET_STATE(state, token) {
            state.token = null;
            localStorage.clear();
            sessionStorage.clear();
        },

4.src/mock.js

javascript 复制代码
/**
 获取用户信息
 */

Mock.mock(RegExp('/userInfo'),'get',(config)=>{
    // 这里无法在header添加authorization,直接跳过
    Result.data = {
        id: random.string(3), // 获取一个3位的随机字符串
        username:'Admin',
        avatar: 'https://tse4-mm.cn.bing.net/th/id/OIP-C.QiENtPtG3CIjC6yr0P-bMQHaFj?w=252&h=188&c=7&r=0&o=5&pid=1.7'
    }
    return Result
})

/**
 登出
 */

Mock.mock(RegExp('/logout'),'post',(config)=>{
    return Result
})

5.效果

六、动态菜单栏开发

①:动态菜单

上面代码中,左侧的菜单栏的数据是写死的,在实际场景中我们不可能这样做,因为菜单是需要根据登录用户的权限动态显示菜单的,也就是用户看到的菜单栏可能是不一样的,这些数据需要去后端访问获取。

首先我们先把写死的数据简化成一个json数组数据,然后for循环展示出来,代码如下

1./src/views/inc/SideMenu.vue

html 复制代码
<template>
    <el-menu
        class="el-menu-vertical-demo"
        background-color="#545c64"
        text-color="#fff"
        active-text-color="#ffd04b"
    >
        <router-link to="/index">
            <el-menu-item index="Index">
                <template slot="title">
                    <i class="el-icon-s-home"></i>
                    <span slot="title">首页</span>
                </template>
            </el-menu-item>
        </router-link>
        <el-submenu :index="menu.name" v-for="menu in menuList" :key="menu.id">
            <template slot="title">
                <i :class="menu.icon"></i>
                <span>{{ menu.title }}</span>
            </template>
            <router-link :to="item.path" v-for="item in menu.children" :key="item.id">
                <el-menu-item :index="item.name">
                    <template slot="title">
                        <i :class="item.icon"></i>
                        <span slot="title">{{ item.title }}</span>
                    </template>
                </el-menu-item>
            </router-link>
        </el-submenu>
    </el-menu>
</template>

<script>
export default {
    // 导航菜单
    name: "SideMenu",
    data() {
        return {}
    },
    computed: {
        menuList: {
            get() {
                return this.$store.state.menus.menuList
            }
        }
    }

}
</script>

<style lang="less" scoped>
.el-menu-vertical-demo {
    height: 100%;
}
</style>

可以看到,用for循环显示数据,那么这样变动菜单栏时候只需要修改menuList即可。效果和之前的完全一样。 menuList的数据一般我们是要请求后端的,所以这里我们定义一个mock接口,因为是动态菜单,一般我们也要考虑到权限问题,所以我们请求数据的时候一般除了动态菜单,还要权限的数据,比如菜单的添加、删除是否有权限,是否能显示该按钮等,有了权限数据我们就定动态决定是否展示这些按钮了。

2.src/mock.js

js 复制代码
/**
 获取用户菜单以及权限接口
 */

Mock.mock('/sys/menuAndAuth','get',(config)=>{
    let menu = [
        {
            id:1,
            name: 'SysManga',
            title: '系统管理',
            icon: 'el-icon-s-operation',
            component: '',
            path: '',
            children: [
                {
                    id:2,
                    name: 'SysUser',
                    title: '用户管理',
                    icon: 'el-icon-s-custom',
                    path: '/sys/user',
                    component: 'sys/User',
                    children: []
                },
                {
                    id:3,
                    name: 'SysRole',
                    title: '角色管理',
                    icon: 'el-icon-rank',
                    path: '/sys/role',
                    component: 'sys/Role',
                    children: []
                },
                {
                    id:4,
                    name: 'SysMenu',
                    title: '菜单管理',
                    icon: 'el-icon-menu',
                    path: '/sys/menu',
                    component: 'sys/Menu',
                    children: []
                }
            ]
        },
        {
            id:5,
            name: 'SysTools',
            title: '系统工具',
            icon: 'el-icon-s-tools',
            path: '',
            component: '',
            children: [
                {
                    id:6,
                    name: 'SysDict',
                    title: '数字字典',
                    icon: 'el-icon-s-order',
                    path: '/sys/dict',
                    component: 'sys/Dict',
                    children: []
                },
            ]
        }
    ]
    let  auth = ['sys:user:list', "sys:user:save", "sys:user:delete"]
    Result.data = {
        menus: menu,
        auths:auth
    }
    return Result
})

综上,我们把加载菜单数据这个动作放在router.js中。Router有个前缀拦截,就是在路由到页面之前我们可以做一些判断或者加载数据。

②:动态路由

1.创建src/store/modules/menus.js 模块来共享菜单相关的全局变量

2.在src/store/index.js中引刚刚创建的menus.js

javascript 复制代码
import menus from "@/store/modules/menus";
   modules: {
        menus
    }

3.src/store/modules/menus.js中添加全局共享变量

javascript 复制代码
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default{
    state: {
        hasRoutes: false, // 是否为第一次加载路由
        menuList: [],
        authList:[],
    },
    getters: {},
    mutations: {
        // 设置菜单列表
        SET_MENU_LIST(state, menuList) {
            state.menuList = menuList;
        },
        // 设置权限列表
        SET_AUTH_LIST(state, authList) {
            state.authList = authList;
        },
        // 设置路由已经加载过
        SET_HAS_ROUTES(state, hasRoutes) {
            state.hasRoutes = hasRoutes;
        },
    },
    actions: {}
}

4.src/router/index.js加载菜单数据

javascript 复制代码
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from "@/views/Login";
import Index from "@/views/Index";
import Home from "@/views/Home";
import store from "@/store";
import {getUserMenuAndAuth} from "@/api/login";

Vue.use(VueRouter)

const routes = [
    {
        path: '/login',
        name: 'login',
        component: Login
    },
    {
        path: '/',
        name: 'home',
        redirect: '/index',
        component: Home,
        children: [
            {
                path: '/index',
                name: 'Index',
                meta: {
                    title: '首页'
                },
                component: Index
            },
        ]
    },
]

const router = new VueRouter({
    mode: 'history',
    base: process.env.BASE_URL,
    routes
})

router.beforeEach((to, from, next) => {
    // 获取到是否为第一个加载路由
    let hasRoutes = store.state.menus.hasRoutes;
    // 获取token
    let token = localStorage.getItem('token');
    // 如果访问的是登录页面,直接放行
    if (to.path === '/login') next()
    // 如果token为空 没有登录 跳转到登录页面
    if (!token) next({path: '/login'})
    // 如果不是第一次动态加载路由(已经登录 并且加载过路由) 无需再次加载 直接放行
    if (hasRoutes) next();
    // 能够执行到这里(代表 已经 登录 并且是第一次加载路由)
    // 获取用户菜单以及权限接口(发送请求)
    getUserMenuAndAuth().then(res => {
        console.log('获取用户菜单以及权限接口', res.data.data);
        // 拿到用户菜单
        store.commit('SET_MENU_LIST', res.data.data.menus)
        // 拿到用户权限
        store.commit('SET_AUTH_LIST', res.data.data.auths)

        // 动态绑定路由
        // 获取当前的路由配置
        let newRoutes = router.options.routes;
        // 置空之前的动态配置
        newRoutes[1].children = []
        console.log('newRoutes前', newRoutes)
        res.data.data.menus.forEach(menu => {
            // 判断是否有子菜单 有子菜单转成路由
            if (menu.children) {
                menu.children.forEach(e => {
                    // 转成路由
                    let router = menuToRouter(e);
                    // 把路由添加到路由管理器  因为要添加到home路由下的children中 所有newRoutes[1].children
                    if (router) newRoutes[1].children.push(router)
                })
            }
        })
      
        // 将新生成的路由逐个添加到现有路由配置中
        newRoutes.forEach(route => {
            router.addRoute(route);
        });

        console.log('newRoutes后',newRoutes)
        // 设置路由是否已经加载过
        hasRoutes = true;
        store.commit('SET_HAS_ROUTES', hasRoutes)

        next({path: to.path});
    })
})


// 导航转成路由
function menuToRouter(menu) {
    // 如果 component为空 无需转换
    if (!menu.component) return null

    let route = {
        name: menu.name,
        path: menu.path,
        meta: {
            icon: menu.icon,
            title: menu.title
        },
    };
    route.component = () => import ('@/views/' + menu.component + '.vue')
    return route
}

export default router

可以看到,我们通过menuToRoute 就是把menu(菜单)数据转换成路由对象 ,然后router.addRoute(route)动态添加路由对象。 同时上面的menu对象中 ,有个menu.component ,这个就是连接对应的组件,我们需要添加上去,比如说**/sys/user**链接对应到 component(sys/User)

这样我们才能绑定添加到路由。所以我会修改mock中的nav的数据成这样:

同时上面router中我们还通过判断是否登录页面,是否有token等判断提前判断是否能加载菜单,同时还做了个开关hasRoute来动态判断是否已经加载过菜单。

还需要在store中定义几个方法用于存储数据,我们定义一个menu模块

这样我们菜单的数据就可以加载了,然后再SideMenu.vue中直接获取store中的menuList数据即可显示菜单出来了。

5.最后效果如下

七、 动态标签页开发

我看别的后台管理系统都有这个,效果是这样的:

element-ui中寻了一圈,发现Tab标签页组件挺符合我们要求的,可以动态增减标签页。

理想的动作是这样的:

  1. 当我们点击导航菜单,上方会添加一个对应的标签,注意不能重复添加,发现已存在标签直接切换到这标签即可
  2. 删除当前标签的时候会自动切换到前一个标签页
  3. 点击标签页的时候会调整到对应的内容页中
    综合Vue的思想,我们可以这样设计:在Store中统一存储:1、当前标签Tab,2、已存在的标签Tab列表,然后页面从Store中获取列表显示,并切换到当前Tab即可。删除时候我们循环当前Tab列表,剔除Tab,并切换到指定Tab。

我们先和左侧菜单一样单独定义一个组件Tabs.vue放在views/文件夹内:

1.src/views/Tabs.vue

html 复制代码
<template>
    <el-tabs v-model="editableTabsValue" closable type="card" @tab-remove="removeTab" @tab-click="clickTab">
        <el-tab-pane v-for="item in editableTabs"
                     :key="item.name"
                     :label="item.title"
                     :name="item.name"></el-tab-pane>
    </el-tabs>
</template>

<script>
export default {
    name: "Tabs",
    data() {
        return {};
    },
    computed: {
        editableTabs: {
            get() {
                return this.$store.state.menus.editableTabs
            },
            set(val) {
                this.$store.state.menus.editableTabs = val
            }
        },
        editableTabsValue: {
            get() {
                return this.$store.state.menus.editableTabsValue
            },
            set(val) {
                this.$store.state.menus.editableTabsValue = val
            }
        },
    },
    methods: {
        removeTab(tabName) {
            let tabs = this.editableTabs;
            let tabValue = this.editableTabsValue;
            // 如果 关闭的时首页直接返回
            if (tabValue === 'Index') return
            // 如果关闭的是当前页面 则寻找下一个页面做为当前页
            if (tabName === tabValue) {
                tabs.forEach((tab, index) => {
                    if (tab.name === tabValue) {
                        // 找下一个 或者前一个页面
                        let nextTab = tabs[index + 1] || tabs[index - 1];
                        if (nextTab) tabValue = nextTab.name;
                    }
                })
            }
            // 替换 标签名
            this.editableTabsValue = tabValue;
            // 过滤出除了关闭的标签
            this.editableTabs = tabs.filter(tab => tab.name !== tabName)

            this.$router.push({name: tabValue})

        },
        clickTab(tab) {
            this.$router.push({name: tab.name})
        }
    }
}
</script>

<style scoped>
</style>

上面代码中,computed表示当其依赖的属性的值发生变化时,计算属性会重新计算,反之,则使用缓存中的属性值。这样我们就可以实时监测Tabs标签的动态变化实时显示(相当于实时get、set)。其他clickTab、removeTab的逻辑其实也还算简单,特别是removeTab注意考虑多种情况就可以。 然后我们来到store中的menu.js,我们添加 editableTabsValue和editableTabs,然后把首页作为默认显示的页面。

2.src/store/modules/menus.js

javascript 复制代码
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default {
    state: {
        hasRoutes: false, // 是否为第一次加载路由
        menuList: [],
        authList: [],
        editableTabsValue: 'Index',
        editableTabs: [
            {
                title: '首页',
                name: 'Index',
            }
        ]
    },
    getters: {},
    mutations: {
        // 设置菜单列表
        SET_MENU_LIST(state, menuList) {
            state.menuList = menuList;
        },
        // 设置权限列表
        SET_AUTH_LIST(state, authList) {
            state.authList = authList;
        },
        // 设置路由已经加载过
        SET_HAS_ROUTES(state, hasRoutes) {
            state.hasRoutes = hasRoutes;
        },
        ADD_TAB(state, tab) {
            // 查看要添加的标签是否已经存在
            let index = state.editableTabs.findIndex(e => e.name === tab.name);
            console.log(tab.name)

            // 没有找打 不存在 则添加
            if (index === -1) {
                state.editableTabs.push({
                    title: tab.title,
                    name: tab.name,
                })
            }
            // 把标签名字改为刚添加的名字
            state.editableTabsValue = tab.name;
        },
        RESET_TAB_STATUS(state) {
            state.menuList = [];
            state.authList = [];
            state.hasRoutes = false;
            state.editableTabsValue = 'Index';
            state.editableTabs = [
                {
                    title: '首页',
                    name: 'Index',
                }
            ]
        }
    },
    actions: {}
}

ok,然后再Home.vue中引入我们Tabs.vue这个组件,添加代码的地方比较零散,所以我就写重要代码出来就好,自行添加到指定的地方哈。

3.src/views/Home.vue

  • 只需引入即可

  • 退出登录时要重置标签的状态

  • 注释掉居中的样式

好了完成了第一步了,现在我们需要点击菜单导航,然后再tabs列表中添加tab标签页,那么我们来到SideMenu.vue,我们给el-menu-item每个菜单都添加一个点击事件:

4.src/views/inc/SideMenu.vue

html 复制代码
<template>
    <el-menu
        :default-active="this.$store.state.menus.editableTabsValue"
        class="el-menu-vertical-demo"
        background-color="#545c64"
        text-color="#fff"
        active-text-color="#ffd04b"
    >
        <router-link to="/index">
            <el-menu-item index="Index" @click="addTab({name: 'Index', title: '首页'})">
                <template slot="title">
                    <i class="el-icon-s-home"></i>
                    <span slot="title">首页</span>
                </template>
            </el-menu-item>
        </router-link>
        <el-submenu :index="menu.name" v-for="menu in menuList" :key="menu.id">
            <template slot="title">
                <i :class="menu.icon"></i>
                <span>{{ menu.title }}</span>
            </template>
            <router-link :to="item.path" v-for="item in menu.children" :key="item.id">
                <el-menu-item :index="item.name" @click="addTab(item)">
                    <template slot="title">
                        <i :class="item.icon"></i>
                        <span slot="title">{{ item.title }}</span>
                    </template>
                </el-menu-item>
            </router-link>
        </el-submenu>
    </el-menu>
</template>

<script>
export default {
    // 导航菜单
    name: "SideMenu",
    data() {
        return {}
    },
    computed: {
        menuList: {
            get() {
                return this.$store.state.menus.menuList
            }
        }
    },
    methods: {
        addTab(tab){
            this.$store.commit('ADD_TAB', tab)
        }
    },

}
</script>

<style lang="less" scoped>
.el-menu-vertical-demo {
    height: 100%;
}
</style>

添加tab标签的时候注意需要激活指定当前标签,也就是设置editableTabsValue。然后我们也添加了setActiveTab方法,方便其他地方指定激活某个标签。

但是当我们刷新浏览器、或者直接通过输入链接打开页面时候就不会自动帮我们根据链接回显激活Tab。

刷新浏览器之后链接/sys/users不变,内容不变,但是Tab却不见了,所以我们需要修补一下,当用户是直接通过输入链接形式打开页面的时候我们也能根据链接自动添加激活指定的tab。那么在哪里添加这个回显的方法呢?router中?其实可以,只不过我们需要做判断,因为每次点击导航都会触发router。有没有更简便的方法?有的!因为刷新或者打开页面都是一次性的行为,所以我们可以在更高层的App.vue中做这个回显动作,具体如下:

5.src\App.vue

html 复制代码
<template>
    <div id="app">
        <router-view/>
    </div>
</template>
<script>
export default {
    name: 'App',
    watch: {
        $route(to, from) {
            if (to.path !== '/login') {
                let object = {
                    name: to.name,
                    title: to.meta.title
                }
                this.$store.commit('ADD_TAB', object)
            }
        }
    }
}
</script>

上面代码可以看到,除了login页面,其他页面都会触发addTabs方法,这样我们就可以添加tab和激活tab了。

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
Hello.Reader7 小时前
Flink ZooKeeper HA 实战原理、必配项、Kerberos、安全与稳定性调优
安全·zookeeper·flink
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
智驱力人工智能7 小时前
小区高空抛物AI实时预警方案 筑牢社区头顶安全的实践 高空抛物检测 高空抛物监控安装教程 高空抛物误报率优化方案 高空抛物监控案例分享
人工智能·深度学习·opencv·算法·安全·yolo·边缘计算
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
数据与后端架构提升之路8 小时前
论系统安全架构设计及其应用(基于AI大模型项目)
人工智能·安全·系统安全