16.3 前端项目设计

vhr 微人事项目的前端使用的技术栈是 vue,下面我们简要介绍前端项目是如何运作并连接到后端服务的。

该项目是通过 Vue-CLI 创建的,具有 vue 脚手架项目的通用结构。

16.3.1 Vue CLI 简介

Vue CLI 是一个基于 Vue.js 进行快速开发的完整系统,提供:

  • 通过 @vue/cli 搭建交互式的项目脚手架。
  • 通过 @vue/cli + @vue/cli-service-global 快速开始零配置原型开发。
  • 一个运行时依赖 (@vue/cli-service),该依赖:
    • 可升级;
    • 基于 webpack 构建,并带有合理的默认配置;
    • 可以通过项目内的配置文件进行配置;
    • 可以通过插件进行扩展。
  • 一个丰富的官方插件集合,集成了前端生态中最好的工具。
  • 一套完全图形化的创建和管理 Vue.js 项目的用户界面。

Vue CLI 致力于将 Vue 生态中的工具基础标准化。它确保了各种构建工具能够基于智能的默认配置即可平稳衔接,这样你可以专注在撰写应用上,而不必花好几天去纠结配置的问题。与此同时,它也为每个工具提供了调整配置的灵活性,无需 eject。

16.3.2 全局配置

vhr 项目使用了“饿了么”开源的 ElementUI 界面组件,在 /src/main.js 文件中引入:

import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI,{size:'small'});

使用 Axios 库做前端和后端的 AJAX 交互,并对网络异常等情况处理后封装到 /src/utils/api.js 中,在 /src/main.js 文件中引入配置为 Vue 的 prototype:

import {postRequest} from "./utils/api";
import {postKeyValueRequest} from "./utils/api";
import {putRequest} from "./utils/api";
import {deleteRequest} from "./utils/api";
import {getRequest} from "./utils/api";
Vue.prototype.postRequest = postRequest;
Vue.prototype.postKeyValueRequest = postKeyValueRequest;
Vue.prototype.putRequest = putRequest;
Vue.prototype.deleteRequest = deleteRequest;
Vue.prototype.getRequest = getRequest;

配置完成后,对后续的所有网络请求,就可以通过类似 this.postRequest(url, param) 方式访问后台逻辑。

项目根目录中的 vue.config.js 全局配置文件中配置了前端如何代理到后端的。

let proxyObj = {};
proxyObj['/ws'] = {
    ws: true,
    target: "ws://localhost:8081"
};
proxyObj['/'] = {
    ws: false,
    target: 'http://localhost:8081',
    changeOrigin: true,
    pathRewrite: {
        '^/': ''
    }
}
module.exports = {
    devServer: {
        host: 'localhost',
        port: 8080,
        proxy: proxyObj
    }
}

上述配置了 npm run serve 启动开发服务器时,主机为 localhost,端口为 8080,使用前端代理到后端 http://localhost:8080 上面获取后台逻辑。

vue.config.js 是一个可选的配置文件,如果项目的 (和 package.json 同级的) 根目录中存在这个文件,那么它会被 @vue/cli-service 自动加载。你也可以使用 package.json 中的 vue 字段,但是注意这种写法需要你严格遵照 JSON 的格式来写。

在 router.js 文件中配置了路由,引入 Login、Home等组件。

import Vue from 'vue'
import Router from 'vue-router'
import Login from './views/Login.vue'
import Home from './views/Home.vue'
import FriendChat from './views/chat/FriendChat.vue'
Vue.use(Router)
export default new Router({
    routes: [
        {
            path: '/',
            name: 'Login',
            component: Login,
            hidden:true
        }, {
            path: '/home',
            name: 'Home',
            component: Home,
            hidden:true,
            meta:{
                roles:['admin','user']
            },
            children:[
                {
                    path: '/chat',
                    name: '在线聊天',
                    component: FriendChat,
                    hidden:true
                }
            ]
        }
    ]
})

16.3.3 用户登录

路由文件 router.js 中将 Web 的根路由到 Login 组件,也就是用户输入 http://localhost:8080/ 就访问的是 ./views/Login.vue 这个组件。

import Login from './views/Login.vue'
export default new Router({
    routes: [
        {
            path: '/',
            name: 'Login',
            component: Login,
            hidden:true
        }, {
...

在 views 目录下的 Login.vue 就是用户登录页面。

<template>
    <div>
        <el-form
                :rules="rules"
                ref="loginForm"
                v-loading="loading"
                element-loading-text="正在登录..."
                element-loading-spinner="el-icon-loading"
                element-loading-background="rgba(0, 0, 0, 0.8)"
                :model="loginForm"
                class="loginContainer">
            <h3 class="loginTitle">系统登录</h3>
            <el-form-item prop="username">
                <el-input size="normal" type="text" v-model="loginForm.username" auto-complete="off"
                          placeholder="请输入用户名"></el-input>
            </el-form-item>
            <el-form-item prop="password">
                <el-input size="normal" type="password" v-model="loginForm.password" auto-complete="off"
                          placeholder="请输入密码" @keydown.enter.native="submitLogin"></el-input>
            </el-form-item>
            <el-checkbox size="normal" class="loginRemember" v-model="checked"></el-checkbox>
            <el-button size="normal" type="primary" style="width: 100%;" @click="submitLogin">登录</el-button>
        </el-form>
    </div>
</template>
<script>
    export default {
        name: "Login",
        data() {
            return {
                loading: false,
                loginForm: {
                    username: 'admin',
                    password: '123'
                },
                checked: true,
                rules: {
                    username: [{required: true, message: '请输入用户名', trigger: 'blur'}],
                    password: [{required: true, message: '请输入密码', trigger: 'blur'}]
                }
            }
        },
        methods: {
            submitLogin() {
                this.$refs.loginForm.validate((valid) => {
                    if (valid) {
                        this.loading = true;
                        this.postKeyValueRequest('/doLogin', this.loginForm).then(resp => {
                            this.loading = false;
                            if (resp) {
                                this.$store.commit('INIT_CURRENTHR', resp.obj);
                                window.sessionStorage.setItem("user", JSON.stringify(resp.obj));
                                let path = this.$route.query.redirect;
                                this.$router.replace((path == '/' || path == undefined) ? '/home' : path);
                            }
                        })
                    } else {
                        this.$message.error('请输入所有字段');
                        return false;
                    }
                });
            }
        }
    }
</script>
<style>
    .loginContainer {
        border-radius: 15px;
        background-clip: padding-box;
        margin: 180px auto;
        width: 350px;
        padding: 15px 35px 15px 35px;
        background: #fff;
        border: 1px solid #eaeaea;
        box-shadow: 0 0 25px #cac6c6;
    }
    .loginTitle {
        margin: 15px auto 20px auto;
        text-align: center;
        color: #505458;
    }
    .loginRemember {
        text-align: left;
        margin: 0px 0px 15px 0px;
    }
</style>

登录页面输入用户名和密码后,单击登录页面,触发 submitLogin() 方法,使用 this.postKeyValueRequest(/src/utils/api.js 中的 export const postKeyValueRequest = (url, params) => {)携带 username 和 password 参数以 post 方式访问 /doLogin (根据代理设置,就是去访问 http://localhost:8081/doLogin, 由 vhr-web 后端项目的 SecurityConfig 配置类中的 configure(HttpSecurity http) 方法中配置的 .formLogin().loginProcessingUrl("/doLogin") 提供服务)完成登录。

            submitLogin() {
                this.$refs.loginForm.validate((valid) => {
                    if (valid) {
                        this.loading = true;
                        this.postKeyValueRequest('/doLogin', this.loginForm).then(resp => {
                            this.loading = false;
                            if (resp) {
                                this.$store.commit('INIT_CURRENTHR', resp.obj);
                                window.sessionStorage.setItem("user", JSON.stringify(resp.obj));
                                let path = this.$route.query.redirect;
                                this.$router.replace((path == '/' || path == undefined) ? '/home' : path);
                            }

16.3.4 加载菜单

首页中根据用户分配的权限加载菜单,最终是由 vhr-mapper 后端项目的 MenuMapper 提供的数据。

public interface MenuMapper {
    List<Menu> getMenusByHrId(Integer hrid);
...

为了在页面导航的过程中,避免多次去后台请求菜单数据,将数据存储在 store 中。

/src/store/index.js 文件中,在 store 中创建 routes 数组。

Vue.use(Vuex)
...
const store = new Vuex.Store({
    state: {
        routes: [],
...
    },
    mutations: {
        initRoutes(state, data) {
            state.routes = data;
        },
...

在 /src/utils/menus.js 文件的 initMenu 方法中,初始化菜单并将数据存储到 store 中。

export const initMenu = (router, store) => {
    if (store.state.routes.length > 0) { //已初始化过
        return;
    }
    //没初始化过,则请求后端接口获取数据进行转换并初始化
    getRequest("/system/config/menu").then(data => {
        if (data) {
            let fmtRoutes = formatRoutes(data); //转换
            router.addRoutes(fmtRoutes);
            store.commit('initRoutes', fmtRoutes); //提交mutation更改state
            store.dispatch('connect');
        }
    })
}
export const formatRoutes = (routes) => {
    let fmRoutes = [];
    routes.forEach(router => {
        let {
            path,
            component,
            name,
            meta,
            iconCls,
            children
        } = router;
        if (children && children instanceof Array) {
            children = formatRoutes(children);
        }
        let fmRouter = {
            path: path,
            name: name,
            iconCls: iconCls,
            meta: meta,
            children: children,
            component(resolve) {
                if (component.startsWith("Home")) {
                    require(['../views/' + component + '.vue'], resolve);
                } else if (component.startsWith("Emp")) {
                    require(['../views/emp/' + component + '.vue'], resolve);
                } else if (component.startsWith("Per")) {
                    require(['../views/per/' + component + '.vue'], resolve);
                } else if (component.startsWith("Sal")) {
                    require(['../views/sal/' + component + '.vue'], resolve);
                } else if (component.startsWith("Sta")) {
                    require(['../views/sta/' + component + '.vue'], resolve);
                } else if (component.startsWith("Sys")) {
                    require(['../views/sys/' + component + '.vue'], resolve);
                }
            }
        }
        fmRoutes.push(fmRouter);
    })
    return fmRoutes;
}

后端 vhr-web 项目的 SystemConfigController 类的 getMenusByHrId 方法(拦截 /system/config/menu 请求)提供菜单数据。

@RestController
@RequestMapping("/system/config")
public class SystemConfigController {
    @Autowired
    MenuService menuService;
    @GetMapping("/menu")
    public List<Menu> getMenusByHrId() {
        return menuService.getMenusByHrId();
    }
}

登录之后刷新的话,数据保存在 vuex 中,在内存里,如果此时用户在其他页面时按了 F5 刷新,页面将重新刷新,而页面重新刷新的话,initMenu 方法将不会调用。那什么时候调用 initMenu 合适呢?

这时,可以使用全局前置守卫(router.beforeEach)来完成 initMenu 的调用。

然后在 /src/main.js 中引入 /src/store/index.js 和 /src/utils/menus.js 在 router.beforeEach 中完成 initMenu 的调用。

import store from './store'
import {initMenu} from "./utils/menus";
...
router.beforeEach((to, from, next) => {
    if (to.path == '/') {
        next();
    }else {
        if (window.sessionStorage.getItem("user")) {
            initMenu(router, store);
            next();
        }else{
            next('/?redirect='+to.path);
        }
    }
})

登录完成后,加载 /home 页面,由 ./views/Home.vue 组件的<el-submenu :index="index+''" v-for="(item,index) in routes" 加载菜单。

<el-aside width="200px">
    <el-menu router unique-opened>
        <el-submenu :index="index+''" v-for="(item,index) in routes" v-if="!item.hidden" :key="index">
            <template slot="title">
                <i style="color: #409eff;margin-right: 5px" :class="item.iconCls"></i>
                <span>{{item.name}}</span>
            </template>
            <el-menu-item :index="child.path" v-for="(child,indexj) in item.children" :key="indexj">
                {{child.name}}
            </el-menu-item>
        </el-submenu>
    </el-menu>
</el-aside>

菜单数据在 routes 中,是通过 computed 计算属性从 store 中获取菜单数据。

computed: {
    routes() {
        return this.$store.state.routes;
    }
},

在 Home 组件中注销登录,需要将 store 中的用户菜单数据清空。

if (cmd == 'logout') {
    this.$confirm('此操作将注销登录, 是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
    }).then(() => {
        this.getRequest("/logout"); //后台注销登录
        window.sessionStorage.removeItem("user")
        this.$store.commit('initRoutes', []); //清空当前登录用户的菜单数据
        this.$router.replace("/");
    }).catch(() => {
        this.$message({
            type: 'info',
            message: '已取消操作'
        });
    });
}