小程序实践多角色下的菜单权限管理

lxf2023-03-11 11:56:02

写在前面

一直以来,权限管理都是 PC 后台的重要底层功能,但随着移动互联网的深入普及、客户运营的精细化管理要求,移动端的管理平台也成为了商家进行日常经营活动不可或缺的一端。

本文将从原生小程序实现菜单、按钮权限管理,介绍一种较简便、合理的技术实现方案,对于使用了其他开发框架的小程序来说,实现原理也是大同小异的。

前端管理菜单展示很简单?

这里先抛出一个问题,前端管理菜单展示很简单?答案确实是简单,甚至前端控制 DOM 有天然的原生 API 支持,特别是在 WEB 端,控制 DOM 几乎是可以随心所欲的事情。

先来撸一条最简单、直接的实现路径:

graph LR
id3((菜单数据)) --> AJAX请求 --> 匹配菜单 --> if/else展示

单从上面的路径来看,前端的处理就是在页面上请求接口,然后根据接口结果控制菜单展示,似乎 10 行左右代码就可以搞定了。

然尔,这是没有从项目整体架构做思考的结果,简单的问题在大量重复的场景下,也会变成麻烦的问题。比如当页面存在几百个时,上述简单的处理就会出现大量重复的代码,并且违背了软件设计的单一职责原则,不利扩展,也不方便使用。

由此带来了几个关于小程序实现菜单控制的现实思考:

  1. 如何提高代码复用性,将菜单处理逻辑尽可能地从页面中抽离出去?
  2. 如何高效地将接口菜单数据匹配实际的 DOM 菜单,通过唯一标识,还是父子菜单层叠式的标识来匹配?
  3. 在大量的视图层的调用上,如何简便操作?

应用场景分析

功能实现之前先来看看背后的制约条件、应用场景,以下展示菜单的组织形式、数据结构,以及小程序应用场景。

菜单组织形式 小程序实践多角色下的菜单权限管理

菜单数据结构

[
  {
    "identifier": "home",
    "url": "/pages/tabs?selectedTabType=home",
    "name": "首页",
    "isMenu": 1,
    "childMenu": [
      {
        "identifier": "customer-pool",
        "url": "/pages/common/message-reminder-list/index",
        "name": "公共客户池",
        "isMenu": 1,
        "childMenu": [
          {
            "identifier": "more",
            "name": "查看更多",
            "isMenu": 0
          },
          {
            "identifier": "get-phone",
            "name": "获取电话",
            "isMenu": 0
          }
        ]
      }
    ]
  }
]

应用场景

小程序实践多角色下的菜单权限管理

可以从组织形式看出来,菜单有类型,支持菜单和按钮,并且支持配置标识、路由;数据结构上是树形结构,且层级不定,需要递归查找指定的数据。

而在小程序应用场景上,几乎没有章法可言,有的地方排列菜单、有点的地方放置按钮;有的地方是单个的形式,有点地方是一组的,这就需要从树形数据中抽象出规律来,以适配不规律的使用场景,简化视图操作。

解决问题

先来看看第二节遗留的三个问题,并思考解决方法

  1. 代码复用

由于在小程序中操作 DOM 没有 WEB 端那么方便,例如可以像 Vue 一样把方法提取为指令、过滤器,来控制元素的展示。但在小程序中可以使用类似mixins的behaviors来提高代码复用。

  1. 菜单匹配

这看起来似乎不是件难事,通过数据里的权限标识去匹配就好,然而这仍然是很繁琐、不利于维护的。给数千以上的元素取唯一标识,光想想就知道不太靠谱了,其次是通过父标识加子标识的方式匹配,比如home:btn,笔者见过上十个标识的叠加匹配,这是件很繁琐、容易出错的事情。

那么要如何处理菜单匹配呢?答案是通过 路由地址+权限标识 匹配,由于路由地址一般是唯一的(共用页面可以加参区分),用于查找当前菜单是比较方便的,而当前菜单的子菜单、子按钮,再通过标识去匹配就好。

  1. 操作简便

由于wxml支持的表达式有限,最好是在behaviors中把当前菜单设置为对象形式的data,然后wxml中直接类似使用wx:if="{{p['home']}}"wx:for="{{p['subButtonList']}}"方式。

示例代码

初始化请求

/**
 * 小程序初始化时请求菜单列表,并且把菜单拼装为对象形式,便于查找
 */
export const getMenuList = async () => {
  // 接口返回的菜单,请求代码省略
  // ...
  const menuList = [];

  const menuSets = {};

  // 拼装成[path]:value形式
  const dfs = list => {
    for (const item of list) {
      if (!item.isMenu) continue;

      if (item.url) {
        menuSets[item.url] = { ...item };
      }

      item.childMenu?.length && dfs(item.childMenu);
    }
  };
  
  dfs(menuList);

  App.menuSets = menuSets;
};

permission.js(behaviors)

// 内部扩展方法,这里也可以不使用
import wxApi from '../utils/wxApi';

/**
 * 通过原生菜单组装页面所需的菜单权限组
 *
 * 判断规则:
 * 通过当前页面路径(或者路径传入:permissionPath)组装数据
 *
 * 返回格式:
 * {
    // 菜单
    'customer-pool':{
      //...菜单信息
    },
    // 按钮
    'search':{
      //...按钮信息
    },
    // 子菜单数组
    subMenuList:[
      //...菜单信息
    ],
    // 子按钮数组
    subButtonList:[
      //...按钮信息
    ],
  }
 *
 */
module.exports = Behavior({
  data: {
    /**
     * 由于getCurrentPages的缺陷,permission是异步设置的
     * 如果需要在js中较早地获取permission,可通过observers监听
     */
    p: {},
  },
  attached: function () {
    this.assembleMenu();
  },
  methods: {
    async assembleMenu() {
      const { permissionPath } = this.data;
      const currentPath = await wxApi.$getCurrentPageUrl(true);
      const $path = permissionPath || currentPath;

      const currentMenu = this.findMenu($path);
      if (!currentMenu) {
        return this.setData({
          p: {},
        });
      }

      const p = this.menuCombination(currentMenu);

      console.log('p', `${$path}\n`, p);

      this.setData({
        p,
      });
    },

    // 查找当前菜单配置
    findMenu(path) {
      const hitKey = Object.keys(App.menuSets)
        .filter(key => path.includes(key))
        .reduce((a, b) => (a.length > b.length ? a : b), '');

      return App.menuSets[hitKey];
    },

    // 组装数据
    menuCombination(menu) {
      const p = {
        name: menu.name,
        identifier: menu.identifier,
      };

      const dfs = (list, _p) => {
        for (const item of list) {
          const newItem = {
            name: item.name,
            identifier: item.identifier,
          };

          if (item.url) newItem.url = item.url;

          _p[item.identifier] = { ...newItem };

          if (item.isMenu) {
            _p.subMenuList = (_p.subMenuList || []).concat({ ...newItem });
          } else {
            _p.subButtonList = (_p.subButtonList || []).concat({ ...newItem });
          }

          if (item.childMenu?.length) dfs(item.childMenu, _p[item.identifier]);
        }
      };

      dfs(menu.childMenu || [], p);

      return p;
    },

    /**
     * 获取指定页面的菜单权限
     * 场景:需要跨页面获取权限
     * 使用示例:const p = this.getPermission('/pages/tabs?selectedTabType=analysis')
     */
    getPermission(path) {
      if (!path) return {};

      const menu = this.findMenu(path);
      if (!menu) return {};

      return this.menuCombination(menu);
    },
  },
});

使用方式(index.js、index.wxml)

Component({
  // behaviors 挂载到App中,不需要每次import
  behaviors: [App.behaviors.permission],
  data: {},
  methods: {},
});
<!-- 按钮 -->
<button wx:if="{{p['btn']}}">按钮</button>

<!-- 按钮组 -->
<button wx:for="{{p['subButtonList']}}">按钮</button>

<!-- 菜单 -->
<view wx:if="{{p['menu']}}">菜单</view>

<!-- 菜单组 -->
<view wx:for="{{p['subMenuList']}}">菜单</view>

<!-- 多层获取 -->
<button wx:if="{{p['menu']['btn']}}">按钮</button>

<view wx:for="{{p['menu']['subMenuList']}}">}}">菜单</view>

如上所示,视图层中极少量的代码即可实现权限控制,主功能也基本完成了,剩下的是一些特殊场景下的兼容,这里就不多赘述了。

如果要进一步实现路由权限(转发、小程序码进来的)、接口权限(前端前置拦截),也可以基于以上的方案稍加调整以实现。

后记

小程序实践多角色下的菜单权限管理技术方案之旅,到此就结束了,如果有更好的实现方式,望不吝赐教。

本文正在参加「 . 」