基于mock数据的菜单权限控制+按钮权限控制

429 阅读6分钟

最近做业务模板的时候,遇到了一个很坑的东西,就是要去做接口权限控制,leader说了,最好再把按钮权限控制也做了。
我了个去啊,要知道我们的目标页面都是跟antd-design-pro一样,都是玩的本地数据。我刚接手的时候,连mock都没接入呢。
坑归坑,老板要的还是要满足的。所以经过深入思考,基于现在项目已经有了mock接口,还差本地数据库,考虑菜单权限应该不是很大,于是就用浏览器的localStorage应该就足够用了。

业务思考

目前业务组那边做的权限控制,我看了有两个项目,总结就一个字:烂。。一个是很老的业务,代码一大堆,但是功能不是很全。另一个项目压根就没实现全部的权限控制。
权限控制目前的实现方式大概有两种

  • 第一种是全局注册路由,所有的页面都卸载router配置表中,然后在路由守卫中去做权限控制。这里接口返回的路由权限是用作权限表来使用。
  • 第二种是局部注册 + 接口返回后addRoutes,局部注册的是一些固定页面,然后从接口拿到匹配的路由权限后,生成路由表再进行增量。 目前主流使用的都是第一种方案,具体为什么,好像是addRoutes有坑,有重复注册的问题,而且在Vue Router4.x版本好像也废弃了这个方法,而是使用了addRoute单此调用。

那么我把这么个情况报告给了老板,结果老板选择了第二套方案😢,说业务组的人好像更熟悉第二套方案。
行吧,第二套就第二套,下面我要思考的就是如何重构现有代码,现在模板库的菜单还是通过配置表的方式来获取,并没有走接口。那么,来吧,撸代码

实现过程

首先菜单配置需要想到菜单需要哪些配置,结合了多个项目,我整理如下

  • 菜单名称
  • 菜单类型:目录/菜单/按钮
  • 前端路由:页面path
  • 菜单图标
  • 组件:需要展示的component组件名称 然后我们需要两套配置表,一个是所有组件的Map表,和一个模拟后端接口的路由配置表,大概如下

image.png

image.png

然后我们的接口应该是在初始化的时候,去查看localStorage,如果里面有就用现成的,不然就初始化,所有先封装一个storage操作对象

export const storage = {
    set(key, value) {
        localStorage.setItem(key, JSON.stringify(value));
    },
    get(key) {
        return localStorage.getItem(key) ? JSON.parse(localStorage.getItem(key)) : undefined;
    },
    remove(key) {
        localStorage.removeItem(key);
    },
};

我们原始的路由表是没有菜单id的,所以在初始化数据的时候,还要加上菜单的id

export function initMenuList() {
    const ids = [];
    let list;
    if (storage.get('menuList')) {
        list = storage.get('menuList');
    } else {
        list = menuList;
        list = cloneListBy(list, 'children', (item) => {
            let id;
            // 生成menu唯一键
            while (ids.includes((id = (Math.random() * 999999).toFixed(0)))) {
                ids.push(id);
            }
            return {
                ...item,
                //是否隐藏菜单
                hideMenu: item.hideMenu || false,
                //菜单Id
                id,
                //菜单图标类型
                iconType: item.iconType || '',
                children: item.children || [],
            };
        });
        storage.set('menuList', list);
    }
    return list;
}

cloneListBy是我自己封装的一个方法,因为没有后端,我自己在玩数组,所以我肯定经常需要对多维数组进行格式化。我干脆写了一个公共方法去做这件事。

/**
 * @desc: 根据指定条件返回新数组
 * @param {*} list 原始多维数组
 * @param {*} key 子组key 如:children
 * @param {*} fn 复制条件
 * @returns [{},{},...]
 */
export function cloneListBy(list, key, fn) {
    return list.map((item) => {
        const o = fn(item);
        if (item[key] && item[key].length) {
            o[key] = cloneListBy(item[key], key, fn);
        }
        return o;
    });
}

然后是写Mock接口,让项目初始化的时候去调取,拿到路由配置表


import Mock from 'mockjs';

import { initMenuList, filterListBy } from '@/utils';

export default Mock.mock(/\/api\/auth\/queryMenuList\/*/, ({ body }) => {
    const { query } = JSON.parse(body);
    let list = initMenuList();
    if (query) {
        list = filterListBy(list, 'children', (item) =>
             item.menuText.indexOf(query) !== -1);
    }
    return Mock.mock({
        code: 'success',
        data: {
            list,
        },
    });
});

filterListBy是过滤多维数组的一个方法

/**
 * @desc: 根据指定条件过滤多维数组
 * @param {*} list 多维数组
 * @param {*} key 子组key 如:children
 * @param {*} fn 过滤条件
 * @returns [{},{},...]
 */
export function filterListBy(list, key, fn) {
    return list.filter((item) => {
        if (item[key] && item[key].length) {
           item[key] = filterListBy(item[key], key, fn);
        }
        const hasItem = fn(item);
        return hasItem || item[key].length;
    });
}

在页面初始化App.vue中,定义以下方法

image.png 这里做了个偷懒的事情,没有去使用store维护菜单数据,因为我觉得菜单修改后,刷新变更也是可以接受的。

现在项目改造已经完成了,页面菜单导航/路由注册都是通过接口来实现的了。

下面就要去写菜单权限配置页面了

先看下成型的页面长什么样

image.png image.png

由于代码比较多,就不写代码了,简单说下实现的逻辑

  1. 首先我之前封装过树形组件,这省了很多工作量,树形组件的数据就是接口返回的数据
  2. 然后右侧是一个多态(支持展示/编辑)的el-form,也是我之前封装好的😍
  3. 这里的限制就是要严格符合: 目录下面只能是菜单 菜单下面只能是按钮 不管是拖拽排序还是编辑上级目录,都要做好这个限制,不然会结构混乱
  4. 而且编辑已存在菜单的情况要,要禁止修改菜单的类型,防止出现子菜单类型混乱的情况
  5. 要根据父级菜单来判断当前菜单的类型,也是要符合之前的限制条件

image.png

以下是几个mock接口的封装

//新增菜单
import Mock from 'mockjs';
import {
 storage, initMenuList, flattenArrayBy, findItemByList,
} from '@/utils';

export default Mock.mock(/\/api\/auth\/addMenu\/*/, ({ body }) => {
    const { data } = JSON.parse(body).params;
    const list = initMenuList();
    const ids = flattenArrayBy(list, 'children', 'id');
    let id;
    // 添加子菜单
    if (data.parentId) {
        while (ids.includes((id = (Math.random() * 999999).toFixed(0)))) {}
        const item = findItemByList(list, 'id', data.parentId);
        if (!item.children) {
            item.children = [];
        }
        item.children.push({
            ...data,
            id,
            children: [],
        });
    } else { // 添加顶级菜单
        list.push({
            ...data,
            children: [],
        });
    }
    storage.set('menuList', list);
    return Mock.mock({
        code: 'success',
        id,
    });
});


//编辑菜单
import Mock from 'mockjs';
import {
 storage, initMenuList, findItemByList, findItemParentByList,
} from '@/utils';

export default Mock.mock(/\/api\/auth\/editMenuById\/*/, ({ body }) => {
    const list = initMenuList();
    const { id, data } = JSON.parse(body).params;
    // parentId如果是数组则取最后一位
    const parentId = Array.isArray(data.parentId)
        ? data.parentId[data.parentId.length - 1]
        : data.parentId;
    const parent = findItemByList(list, 'id', parentId) || list;
    const index = (parent.children || parent).findIndex((item) => item.id === id);
    // 如果没有层级移动
    if (index > -1) {
        const item = (parent.children || parent)[index];
        for (const key in data) {
            if (key !== 'children') {
                item[key] = data[key];
            }
        }
    } else {
        const lastParent = findItemParentByList(list, 'id', id);
        const lastIndex = lastParent.children.findIndex((item) => item.id === id);
        const item = lastParent.children.splice(lastIndex, 1)[0];
        (parent.children || parent).push(item);
    }

    storage.set('menuList', list);
    return Mock.mock({
        code: 'success',
    });
});

//删除菜单
import Mock from 'mockjs';
import { storage, initMenuList } from '@/utils';

let list;
function findItemParent(list, key, val) {
    for (const o of list || []) {
        if ((o.children || []).findIndex((e) => e[key] === val) !== -1) return o;
        const o_ = findItemParent(o.children, key, val);
        if (o_) return o_;
    }
}
export default Mock.mock(/\/api\/auth\/removeMenu\/*/, ({ body }) => {
    const list = initMenuList();
    const { id } = JSON.parse(body).params;
    const item = findItemParent(list, 'id', id);
    // 如果是子级菜单
    if (item) {
        item.children = item.children.filter((item) => item.id !== id);
    } else { // 如果是顶级菜单
        const index = list.findIndex((item) => item.id === id);
        list.splice(index, 1);
    }
    storage.set('menuList', list);
    return Mock.mock({
        code: 'success',
    });
});

//拖拽操作
import Mock from 'mockjs';
import { storage, initMenuList, findItemParentByList } from '@/utils';

export default Mock.mock(/\/api\/auth\/dropMenu\/*/, ({ body }) => {
    const { dragId, dropId, type } = JSON.parse(body).params;
    const list = initMenuList();
    // 被拖拽节点父级
    let dragParent = findItemParentByList(list, 'id', dragId);
    // 目标节点父级
    let dropParent = findItemParentByList(list, 'id', dropId);
    // 如果是插入
    if (type === 'inner') {
        // 被拖拽节点为顶级菜单兜底
        dragParent = dragParent || list;
        // 插入操作不会被插入到顶级菜单下
        dropParent = dropParent || list.find((item) => item.id === dropId);
        // 被拖拽节点下标
        const dragIndex = (dragParent.children || dragParent).findIndex(
            (item) => item.id === dragId,
        );
        // 被拖拽节点实例
        const dragItem = (dragParent.children || dragParent).splice(dragIndex, 1)[0];
        // 目标节点children下插入
        dropParent.children.push(dragItem);
    } else { // 排序
        dragParent = dragParent || list;
        dropParent = dropParent || list;
        const dragIndex = (dragParent.children || dragParent).findIndex(
            (item) => item.id === dragId,
        );
        const dragItem = (dragParent.children || dragParent).splice(dragIndex, 1)[0];
        const dropIndex = (dropParent.children || dropParent).findIndex(
            (item) => item.id === dropId,
        );
        const dropItem = (dropParent.children || dropParent)[dropIndex];
        if (type === 'before') {
            (dropParent.children || dropParent).splice(dropIndex, 1, ...[dragItem, dropItem]);
        } else {
            (dropParent.children || dropParent).splice(dropIndex, 1, ...[dropItem, dragItem]);
        }
    }
    storage.set('menuList', list);
    return Mock.mock({
        code: 'success',
    });
});