Vue2.x通用权限管理实践思路

330 阅读3分钟

权限管理在后台应用中特别常见,通过本文你将了解到权限管理中的几个难点的解决方案:权限的加载与卸载, 基于权限的动态菜单生成、基于路由钩子的动态路由,权限本地持久化与状态管理,首页跳转机制,404机制等

权限定义

定义好菜单、路由表(页面)、按钮权限集,后续可通过权限标志匹配权限集。

// 菜单权限
const PERMISSION_MENU = {
    merchantMgt: 'merchantMgt', // 客户管理
    device: 'device', // 设备管理
    orderMgt: 'orderMgt', // 订单管理
    financeMgt: 'financeMgt', // 财务管理
};
// 路由权限
export const PERMISSION_ROUTE = {
    merchantInParts: 'merchantInParts', // 商户进件
    merchantIndex: 'merchantIndex', // 商户列表
    device: 'device', // 设备管理
    orderOverview: 'orderOverview', // 交易概览
    orderDetail: 'orderDetail', // 交易订单
    orderRefund: 'orderRefund', // 退款订单
    financeOverview: 'financeOverview', // 对账概览
};
// 按钮权限
export const PERMISSIONS = {
    [PERMISSION_ROUTE.merchantInParts]: {
        export: 'merchantInParts:export',
        resend: 'merchantInParts:resend',
        close: 'merchantInParts:close',
    },
    [PERMISSION_ROUTE.merchantIndex]: {
        add: 'merchantIndex:add',
        modify: 'merchantIndex:modify',
        delete: 'merchantIndex:delete',
    },
    [PERMISSION_ROUTE.device]: {
        add: 'device:add',
        modify: 'device:modify',
        delete: 'device:delete',
        relation: 'device:relation',
        unbinding: 'device:unbinding',
    },
    [PERMISSION_ROUTE.orderDetail]: {
        export: 'orderDetail:export',
    },
    [PERMISSION_ROUTE.orderRefund]: {
        export: 'orderRefund:export',
    },
};

// 权限路由
export const permissionRoutes = [
    {
        path: '/merchantInParts',
        component: () => import(/* webpackChunkName: "merchantInParts" */ '../views/merchantMgt/inParts.vue'),
        meta: { title: '商户进件', permission: PERMISSION_ROUTE.merchantInParts },
    },
    {
        path: '/merchantIndex',
        component: () => import(/* webpackChunkName: "merchantIndex" */ '../views/merchantMgt/index.vue'),
        meta: { title: '商户列表', permission: PERMISSION_ROUTE.merchantIndex },
    },
    {
        path: '/device',
        component: () => import(/* webpackChunkName: "device" */ '../views/device/index.vue'),
        meta: { title: '设备管理', permission: PERMISSION_ROUTE.device },
    },
    {
        path: '/orderOverview',
        component: () => import(/* webpackChunkName: "orderOverview" */ '../views/orderMgt/overview/index.vue'),
        meta: { title: '交易概览', permission: PERMISSION_ROUTE.orderOverview },
    },
    {
        path: '/orderDetail',
        component: () => import(/* webpackChunkName: "orderDetail" */ '../views/orderMgt/detail/index.vue'),
        meta: { title: '交易订单', permission: PERMISSION_ROUTE.orderDetail },
    },
    {
        path: '/orderRefund',
        component: () => import(/* webpackChunkName: "orderRefund" */ '../views/orderMgt/refund/index.vue'),
        meta: { title: '退款订单', permission: PERMISSION_ROUTE.orderRefund },
    },
    {
        path: '/financeOverview',
        component: () => import(/* webpackChunkName: "financeOverview" */ '../views/financeMgt/overview/index.vue'),
        meta: { title: '对账概览', permission: PERMISSION_ROUTE.financeOverview },
    },
];

// 权限菜单
export const permissionMenus = [
    {
        icon: 'el-icon-user-solid',
        index: PERMISSION_MENU.merchantMgt,
        title: '商户管理',
        subs: [
            {
                code: PERMISSION_ROUTE.merchantInParts,
                index: getPermissionRoutePath(PERMISSION_ROUTE.merchantInParts),
                title: '商户进件',
            },
            { code: PERMISSION_ROUTE.merchantIndex, index: getPermissionRoutePath(PERMISSION_ROUTE.merchantIndex), title: '商户列表' },
        ],
    },
    {
        icon: 'el-icon-copy-document',
        code: PERMISSION_MENU.device,
        index: getPermissionRoutePath(PERMISSION_ROUTE.device),
        title: '设备管理',
    },
    {
        icon: 'el-icon-s-order',
        index: PERMISSION_MENU.orderMgt,
        title: '订单管理',
        subs: [
            {
                code: PERMISSION_ROUTE.orderOverview,
                index: getPermissionRoutePath(PERMISSION_ROUTE.orderOverview),
                title: '交易概览',
            },
            {
                code: PERMISSION_ROUTE.orderDetail,
                index: getPermissionRoutePath(PERMISSION_ROUTE.orderDetail),
                title: '交易订单',
            },
            {
                code: PERMISSION_ROUTE.orderRefund,
                index: getPermissionRoutePath(PERMISSION_ROUTE.orderRefund),
                title: '退款订单',
            },
        ],
    },
    {
        icon: 'el-icon-s-finance',
        index: PERMISSION_MENU.financeMgt,
        title: '财务管理',
        subs: [
            {
                code: PERMISSION_ROUTE.financeOverview,
                index: getPermissionRoutePath(PERMISSION_ROUTE.financeOverview),
                title: '对账概览',
            },
        ],
    },
];

function getPermissionRoutePath(permission) {
    return permissionRoutes.find((route) => route.meta.permission === permission)?.path;
}

权限树匹配

权限匹配后的权限树形结构大致如下:

对应方法:

// 根据权限匹配授权路由表
export function getPermissionRoutes(permissionsTree) {
    const permissionsMap = {};
    function reCallback(tree = []) {
        tree.forEach((v) => {
            permissionsMap[v.code] = true;
            if (v.children) {
                reCallback(v.children);
            }
        });
    }
    reCallback(permissionsTree);
    return permissionRoutes.filter((route) => !route.meta.permission || permissionsMap[route.meta.permission]);
}


// 根据权限匹配菜单
export function getPermissionMenus(permissionsTree, menus = permissionMenus) {
    if (!permissionsTree) {
        return [];
    }
    const ans = [];
    menus.forEach((menu) => {
        // 菜单取index, page取code
        const tree = permissionsTree.find((v) => v.code === (menu.code ?? menu.index));
        if (tree) {
            if (menu.subs) {
                const subs = tree.children ? getPermissionMenus(tree.children, menu.subs) : [];
                if (subs.length) {
                    ans.push({
                        ...menu,
                        subs,
                    });
                }
            } else {
                ans.push(menu);
            }
        }
    });
    return ans;
}


const cache = new Map();
// 判断是否含有该授权标志
export function hasPermission(permission) {
    if (!permission) {
        return true;
    }
    // 获取所有的权限
    const permissionMap = cache.get(store.getters.permissionTree);
    if (permissionMap) {
        return permissionMap.has(permission);
    } else {
        let map = new Map();
        let list = store.getters.permissionTree;
        while (list.length) {
            let newArr = [];
            list.forEach((v) => {
                map.set(v.code, true);
                if (v.children) {
                    newArr = newArr.concat(v.children);
                }
            });
            list = newArr;
        }
        cache.clear();
        cache.set(store.getters.permissionTree, map);
        return map.has(permission);
    }
}

路由的加载与重载

先定义好静态路由表,404在动态路由中最后添加,以便没有授权的用户与授权的用户都能成功响应404页面,通过ADD_ROUTES设置是否加载路由,可实现登录,登出手动加载与卸载权限路由,redirect函数中根据路由权限自定重定向到首页。

import Vue from 'vue';
import VueRouter from 'vue-router';
import store from '@/store';
import { ADD_ROUTES } from '@/store/mutation-types';
Vue.use(VueRouter);

// 静态路由表
const constantRouters = [
    {
        path: '/login',
        component: () => import(/* webpackChunkName: "login" */ '../components/page/Login.vue'),
        meta: { title: '登录' },
    },
    {
        path: '/404',
        component: () => import(/* webpackChunkName: "404" */ '../components/page/404.vue'),
        meta: { title: '404' },
    },
    {
        path: '/403',
        component: () => import(/* webpackChunkName: "403" */ '../components/page/403.vue'),
        meta: { title: '403' },
    },
    {
        path: '/test',
        component: () => import('../components/page/test.vue'),
        meta: { title: 'test' },
    },
];


const router = new VueRouter({
    mode: 'history',
    base: import.meta.env.BASE_URL,
    routes: constantRouters,
});

// 路由加载标识设置
export function setAddRoutes(val) {
    store.commit(ADD_ROUTES, val);
}


//使用钩子函数对路由进行权限跳转
router.beforeEach((to, from, next) => {
    document.title = to.meta?.title ? `${to.meta.title} | 管理后台` : '管理后台';
    const token = localStorage.getItem('token');
    if (!token && to.path !== '/login') {
        next({
            path: '/login',
            replace: true,
        });
    } else {
        // 手动添加route 考虑登出登录之后,isAddRoute仍然存在
        if (!store.state.addRoutes) {
            // 根据name覆盖原有的路由
            router.addRoute({
                name: 'Index',
                path: '/',
                redirect: () => {
                    const routes = store.getters.routes;
                    return routes[0]?.path ?? '/login';
                },
                component: () => import(/* webpackChunkName: "home" */ '../components/common/Home.vue'),
                meta: { title: '首页' },
                children: store.getters.routes,
            });
            // 覆盖默认的 404
            router.addRoute({
                // 会匹配所有路径
                name: '404',
                path: '*',
                redirect: '/404',
            });
            setAddRoutes(true);
            // 未加载动态路由之前(如:刷新页面)初始化的时候会被重定向到404页面,所以这里需要回退到 redirectedFrom
            const toPath = to.path === '/404' && to.redirectedFrom ? to.redirectedFrom : to.fullPath;
            //触发重定向 router.vuejs.org/zh/guide/advanced/dynamic-routing.html#%E5%9C%A8%E5%AF%BC%E8%88%AA%E5%AE%88%E5%8D%AB%E4%B8%AD%E6%B7%BB%E5%8A%A0%E8%B7%AF%E7%94%B1
            next(toPath);
        } else {
            next();
        }
    }
});


export default router;

状态持久化处理

import router from '@/router';
import { setLocal } from '@/utils/storage';
import { ADD_ROUTES } from '../mutation-types';


const user = {
    actions: {
        async login({ commit, dispatch }, data) {
            setLocal('loginInfo', data);
            localStorage.setItem('token', data.access_token);
            localStorage.setItem('ms_username', data.loginId);
            if (!data.staffman?.length) {
                commit('SET_PERMISSION', []);
                commit(ADD_ROUTES, false);
                return;
            }
            await dispatch('getPermissionData', data.staffman?.[0]).then(() => {
                commit(ADD_ROUTES, false);
            });
        },
        logout({ commit }) {
            localStorage.removeItem('loginInfo');
            localStorage.removeItem('ms_username');
            localStorage.removeItem('token');
            commit('SET_PERMISSION', []);
            commit(ADD_ROUTES, false);
            router.push('/login');
        },
    },
};


export default user;
import { getTreePrivileges } from '@/api/account';
import { getLocal, setLocal } from '@/utils/storage';
import { ADD_ROUTES } from '../mutation-types';


const permission = {
    state: {
        permissionTree: getLocal('permissionTree') || [],
    },
    mutations: {
        SET_PERMISSION(state, permissions) {
            state.permissionTree = permissions;
            setLocal('permissionTree', permissions);
        },
    },
    actions: {
        clearPermissions({ commit }) {
            commit('SET_PERMISSION', []);
            commit(ADD_ROUTES, false);
        },
        getPermissionData({ commit }, { market, marketName, marketId } = {}) {
            return getTreePrivileges({
                market,
                marketName,
                marketId,
                terminal: 'Web聚合',
            }).then((res) => {
                commit('SET_PERMISSION', res.data.data);
            });
        },
    },
};


export default permission;
import { getPermissionMenus, getPermissionRoutes } from '@/utils/permission';


const getters = {
    token: (state) => state.user.token,
    userinfo: (state) => state.user.userInfo,
    permissionTree: (state) => state.permission.permissionTree,
    menus: (state) => getPermissionMenus(state.permission.permissionTree),
    routes: (state) => getPermissionRoutes(state.permission.permissionTree),
};
export default getters;
login(data).then((res) => {
    this.$message.success('登录成功');
    this.$store.dispatch('login', res.data.data).then(() => {
        // web ,没有配置权限的用户登录管理后台时,提示优化
        if (!this.$store.getters.permissionTree?.length) {
            this.$message.error('登录用户,暂无管理权限');
            return;
        }
        this.$router.push({
            name: 'Index',
        });
    });
});

全局方法和指令

指令没有全局方法好用,设置好页面与按钮之前的关联,可以直接读取route.meta.permission搭配按钮标识即可直接使用

全局方法:

Vue.prototype.hasPermission = function (val) {
    const metaPermission = this.$route.meta.permission;
    if (!metaPermission) {
        return false;
    }
    return hasPermission(PERMISSIONS[metaPermission]?.[val]);
};
<template>
   <el-button v-if="hasPermission('add')" type="primary" icon="el-icon-plus" @click="addCustomer">新增</el-button>
</template>

指令

import { hasPermission } from '@/utils/permission';


export default {
    inserted(el, binding, vnode) {
        const { value } = binding;
        if (typeof value !== 'string') {
            throw new Error('need roles Like v-permission=[admin]');
        }
        if (!hasPermission(value)) {
            el.parentNode && el.parentNode.removeChild(el);
        }
    },
};
import permission from './permission';
import Vue from 'vue';
Vue.directive('permission', permission);
<template>
   <el-button v-permission="'device:add'" type="primary" icon="el-icon-plus" @click="addCustomer">新增</el-button>
</template>