Ant Design Pro V4 -- 后端动态菜单

01 版本信息

  • Ant Design Pro v4.5.0
  • umi v3.2.14
  • umi-request v1.0.8
  • Pro-layout v6.9.0
  • TypeScript v4.0.5
  • Flask后端 v1.1.2

02 过程思路

  • 后端 使用 flask 提供菜单接口
  • 使用react hooks的useEffect 中使用dva的dispatch来请求菜单
  • BasicLayout.tsx 将从后台请求返回的菜单数据,传递给 menuDataRender属性中进行渲染

03 代码实现

Flask后端接口
  • 返回的数据中一定要有path, name。name可以覆盖前端写的name。
  • 返回的数据可以设置icon,但是不起作用,文章后面有提供解决方案。
  • 返回的数据的authority可以覆盖前端写的authority。如果返回的数据没有authority,则前端写的authority会生效。
from flask import jsonify, g
from app.libs.error_code import NotFound, DeleteSuccess, AuthFailed
from app.libs.redprint import Redprint
from app.libs.token_auth import auth
from app.models.base import db
from app.models.user import User

# Redprint
api = Redprint("user")

@api.route("/menu", methods=["GET"])
def get_menu():
    routes = [
        {
            "path": "/",
            "name": "home",
            "icon": "HomeOutlined",
            "component": "./home/index",
        },
        {
            "path": "/venue",
            "name": "venue",
            "icon": "CarryOutOutlined",
            "routes": [
                {
                    "name": "T8-305",
                    "path": "/venue/view/T8-305",
                    "component": "./venue/index",
                },
                {
                    "name": "T8-306",
                    "path": "/venue/view/T8-306",
                    "component": "./venue/index",
                },
            ],
        },
        {
            "path": "/officehour",
            "name": "officehour",
            "icon": "CarryOutOutlined",
            "authority": ["admin", "user"],
            "routes": [
                {
                    "name": "hejing",
                    "path": "/officehour/view/hejing",
                    "component": "./venue/index",
                },
                {
                    "name": "helen",
                    "path": "/officehour/view/helen",
                    "component": "./venue/index",
                },
            ],
        },
        {
            "path": "/form",
            "icon": "form",
            "name": "form",
            "routes": [
                {"path": "/", "redirect": "/form/basic-form",},
                {
                    "name": "basic-form",
                    "icon": "smile",
                    "path": "/form/basic-form",
                    "component": "./form/basic-form",
                },
                {
                    "name": "step-form",
                    "icon": "smile",
                    "path": "/form/step-form",
                    "component": "./form/step-form",
                },
                {
                    "name": "advanced-form",
                    "icon": "smile",
                    "path": "/form/advanced-form",
                    "component": "./form/advanced-form",
                },
            ],
        },
        {"path": "/", "redirect": "/list/table-list",},
        {
            "name": "table-list",
            "icon": "smile",
            "path": "/list/table-list",
            "component": "./list/table-list",
        },
        {
            "name": "account",
            "icon": "user",
            "path": "/account",
            "routes": [
                {"path": "/", "redirect": "/account/center",},
                {
                    "name": "center",
                    "icon": "smile",
                    "path": "/account/center",
                    "component": "./account/center",
                },
                {
                    "name": "settings",
                    "icon": "smile",
                    "path": "/account/settings",
                    "component": "./account/settings",
                },
            ],
        },
        {"component": "404",},
    ]

    return jsonify(routes)

定义 menu 模型 menu.ts

src\models\menu.ts

import { Effect, Reducer } from 'umi';
import { MenuDataItem } from '@ant-design/pro-layout';
import { getMenuData } from '@/services/menu';

export interface MenuModelState {
  menuData: MenuDataItem[];
  loading: boolean;
}

export interface MenuModelType {
  namespace: 'menu';
  state: {
    menuData: []; //  存储menu数据
    loading: true; // loading的初始值为true
  };
  effects: {
    fetchMenu: Effect;
  };
  reducers: {
    saveMenuData: Reducer<MenuModelState>;
  };
}

const MenuModel: MenuModelType = {
  namespace: 'menu',
  state: {
    menuData: [],
    loading: true,
  },

  effects: {
    *fetchMenu(_, { put, call }) {
      const response = yield call(getMenuData);
      console.log('yield call(getMenuData)');
      console.log(response);
      yield put({
        type: 'saveMenuData',
        payload: response,
      });
    },
  },

  reducers: {
    saveMenuData(state, action) {
      return {
        ...state,
        menuData: action.payload || [],
        loading: false, // 后台数据返回了,loading就改成false
      };
    },
  },
};
export default MenuModel;

在connect中定义menu的类型

src\models\connect.d.ts

import type { MenuDataItem, Settings as ProSettings } from '@ant-design/pro-layout';
import { GlobalModelState } from './global';
import { UserModelState } from './user';
import type { StateType } from './login';
import { MenuModelState } from './menu';

export { GlobalModelState, UserModelState };

export type Loading = {
  global: boolean;
  effects: Record<string, boolean | undefined>;
  models: {
    global?: boolean;
    menu?: boolean;
    setting?: boolean;
    user?: boolean;
    login?: boolean;
  };
};

export type ConnectState = {
  global: GlobalModelState;
  loading: Loading;
  settings: ProSettings;
  user: UserModelState;
  login: StateType;
  menu: MenuModelState; // 定义menu的类型,MenuModelState是在src/models/menu.ts中定义的
};

export type Route = {
  routes?: Route[];
} & MenuDataItem;

获取菜单service

src\services\menu.ts


import { Constants } from '@/utils/constants';
import request from '@/utils/request';

export async function getMenuData(): Promise<any> {
  return request(`${Constants.baseUrl}/v1/user/menu`, {
    method: 'GET',
    data: { },
  });
}

后台返回的数据在前端项目中也还是要写的

config\config.ts

// https://umijs.org/config/
import { defineConfig } from 'umi';
import defaultSettings from './defaultSettings';
import proxy from './proxy';

const { REACT_APP_ENV } = process.env;

export default defineConfig({
  hash: true,
  antd: {},
  dva: {
    hmr: true,
  },
  history: {
    type: 'browser',
  },
  locale: {
    // default zh-CN
    default: 'zh-CN',
    antd: true,
    // default true, when it is true, will use `navigator.language` overwrite default
    baseNavigator: true,
  },
  dynamicImport: {
    loading: '@/components/PageLoading/index',
  },
  targets: {
    ie: 11,
  },
  // umi routes: https://umijs.org/docs/routing
  routes: [
    {
      path: '/',
      component: '../layouts/BlankLayout',
      routes: [
        {
          path: '/user',
          component: '../layouts/UserLayout',
          routes: [
            {
              path: '/user/login',
              name: 'login',
              component: './User/login',
            },

            {
              path: '/user',
              redirect: '/user/login',
            },
            {
              name: 'register-result',
              icon: 'smile',
              path: '/user/register-result',
              component: './user/register-result',
            },
            {
              name: 'register',
              icon: 'smile',
              path: '/user/register',
              component: './user/register',
            },
            {
              component: '404',
            },
          ],
        },
        {
          path: '/',
          component: '../layouts/BasicLayout',
          Routes: ['src/pages/Authorized'],
          // authority: ['admin', 'user'],

          routes: [
            // home
            {
              path: '/',
              name: 'home',
              icon: 'HomeOutlined',
              component: './home/index',
            },
            // venue
            {
              path: '/venue',
              name: 'venue',
              icon: 'CarryOutOutlined',
              routes: [
                {
                  name: 'T8-305',
                  path: '/venue/view/T8-305',
                  component: './venue/index',
                },
                {
                  name: 'T8-306',
                  path: '/venue/view/T8-306',
                  component: './venue/index',
                },
              ],
            },
            // officehour
            {
              path: '/officehour',
              name: 'officehour',
              icon: 'CarryOutOutlined',
              authority: ['admin', 'user'],
              routes: [
                {
                  name: 'hejing',
                  path: '/officehour/view/hejing',
                  component: './venue/index',
                },
                {
                  name: 'helen',
                  path: '/officehour/view/helen',
                  component: './venue/index',
                },
              ],
            },
            // {
            //   path: '/',
            //   redirect: '/dashboard/analysis',
            // },
            // {
            //   path: '/dashboard',
            //   name: 'dashboard',
            //   icon: 'dashboard',
            //   routes: [
            //     {
            //       path: '/',
            //       redirect: '/dashboard/analysis',
            //     },
            //     {
            //       name: 'analysis',
            //       icon: 'smile',
            //       path: '/dashboard/analysis',
            //       component: './dashboard/analysis',
            //     },
            //     {
            //       name: 'monitor',
            //       icon: 'smile',
            //       path: '/dashboard/monitor',
            //       component: './dashboard/monitor',
            //     },
            //     {
            //       name: 'workplace',
            //       icon: 'smile',
            //       path: '/dashboard/workplace',
            //       component: './dashboard/workplace',
            //     },
            //   ],
            // },
            {
              path: '/form',
              icon: 'form',
              name: 'form',

              routes: [
                {
                  path: '/',
                  redirect: '/form/basic-form',
                },
                {
                  name: 'basic-form',
                  icon: 'smile',
                  path: '/form/basic-form',
                  component: './form/basic-form',
                },
                {
                  name: 'step-form',
                  icon: 'smile',
                  path: '/form/step-form',
                  component: './form/step-form',
                },
                {
                  name: 'advanced-form',
                  icon: 'smile',
                  path: '/form/advanced-form',
                  component: './form/advanced-form',
                },
              ],
            },
            // {
            //   path: '/list',
            //   icon: 'table',
            //   name: 'list',

            //   routes: [
            //     {
            //       path: '/list/search',
            //       name: 'search-list',
            //       component: './list/search',
            //       routes: [
            //         {
            //           path: '/list/search',
            //           redirect: '/list/search/articles',
            //         },
            //         {
            //           name: 'articles',
            //           icon: 'smile',
            //           path: '/list/search/articles',
            //           component: './list/search/articles',
            //         },
            //         {
            //           name: 'projects',
            //           icon: 'smile',
            //           path: '/list/search/projects',
            //           component: './list/search/projects',
            //         },
            //         {
            //           name: 'applications',
            //           icon: 'smile',
            //           path: '/list/search/applications',
            //           component: './list/search/applications',
            //         },
            //       ],
            //     },
            {
              path: '/',
              redirect: '/list/table-list',
            },
            {
              name: 'table-list',
              icon: 'smile',
              path: '/list/table-list',
              component: './list/table-list',
            },
            //     {
            //       name: 'basic-list',
            //       icon: 'smile',
            //       path: '/list/basic-list',
            //       component: './list/basic-list',
            //     },
            //     {
            //       name: 'card-list',
            //       icon: 'smile',
            //       path: '/list/card-list',
            //       component: './list/card-list',
            //     },
            //   ],
            // },
            // {
            //   path: '/profile',
            //   name: 'profile',
            //   icon: 'profile',
            //   routes: [
            //     {
            //       path: '/',
            //       redirect: '/profile/basic',
            //     },
            //     {
            //       name: 'basic',
            //       icon: 'smile',
            //       path: '/profile/basic',
            //       component: './profile/basic',
            //     },
            //     {
            //       name: 'advanced',
            //       icon: 'smile',
            //       path: '/profile/advanced',
            //       component: './profile/advanced',
            //     },
            //   ],
            // },
            // {
            //   name: 'result',
            //   icon: 'CheckCircleOutlined',
            //   path: '/result',
            //   routes: [
            //     {
            //       path: '/',
            //       redirect: '/result/success',
            //     },
            //     {
            //       name: 'success',
            //       icon: 'smile',
            //       path: '/result/success',
            //       component: './result/success',
            //     },
            //     {
            //       name: 'fail',
            //       icon: 'smile',
            //       path: '/result/fail',
            //       component: './result/fail',
            //     },
            //   ],
            // },
            // {
            //   name: 'exception',
            //   icon: 'warning',
            //   path: '/exception',
            //   routes: [
            //     {
            //       path: '/',
            //       redirect: '/exception/403',
            //     },
            //     {
            //       name: '403',
            //       icon: 'smile',
            //       path: '/exception/403',
            //       component: './exception/403',
            //     },
            //     {
            //       name: '404',
            //       icon: 'smile',
            //       path: '/exception/404',
            //       component: './exception/404',
            //     },
            //     {
            //       name: '500',
            //       icon: 'smile',
            //       path: '/exception/500',
            //       component: './exception/500',
            //     },
            //   ],
            // },
            {
              name: 'account',
              icon: 'user',
              path: '/account',
              routes: [
                {
                  path: '/',
                  redirect: '/account/center',
                },
                {
                  name: 'center',
                  icon: 'smile',
                  path: '/account/center',
                  component: './account/center',
                },
                {
                  name: 'settings',
                  icon: 'smile',
                  path: '/account/settings',
                  component: './account/settings',
                },
              ],
            },
            // {
            //   name: 'editor',
            //   icon: 'highlight',
            //   path: '/editor',
            //   routes: [
            //     {
            //       path: '/',
            //       redirect: '/editor/flow',
            //     },
            //     {
            //       name: 'flow',
            //       icon: 'smile',
            //       path: '/editor/flow',
            //       component: './editor/flow',
            //     },
            //     {
            //       name: 'mind',
            //       icon: 'smile',
            //       path: '/editor/mind',
            //       component: './editor/mind',
            //     },
            //     {
            //       name: 'koni',
            //       icon: 'smile',
            //       path: '/editor/koni',
            //       component: './editor/koni',
            //     },
            //   ],
            // },

            {
              component: '404',
            },
          ],
        },
      ],
    },
  ],
  // Theme for antd: https://ant.design/docs/react/customize-theme-cn
  theme: {
    'primary-color': defaultSettings.primaryColor,
  },
  title: false,
  ignoreMomentLocale: true,
  proxy: proxy[REACT_APP_ENV || 'dev'],
  publicPath: '/dist/', //在生成的js路径前,添加这个路径
  manifest: {
    basePath: '/',
  },
});

菜单渲染

src\layouts\BasicLayout.tsx

/**
 * Ant Design Pro v4 use `@ant-design/pro-layout` to handle Layout.
 * You can view component api by:
 * https://github.com/ant-design/ant-design-pro-layout
 */
import type {
  MenuDataItem,
  BasicLayoutProps as ProLayoutProps,
  Settings,
} from '@ant-design/pro-layout';
import ProLayout, { DefaultFooter, SettingDrawer } from '@ant-design/pro-layout';
import React, { useEffect, useMemo, useRef } from 'react';
import type { Dispatch } from 'umi';
import { Link, useIntl, connect, history } from 'umi';
// import { GithubOutlined } from '@ant-design/icons';
import { Result, Button } from 'antd';
import Authorized from '@/utils/Authorized';
import RightContent from '@/components/GlobalHeader/RightContent';
import type { ConnectState } from '@/models/connect';
import { getMatchMenu } from '@umijs/route-utils';
import logo from '../assets/logo.png';

// 导入对应的Icon
import {
  SmileOutlined,
  CarryOutOutlined,
  FormOutlined,
  UserOutlined,
  HomeOutlined,
  PicLeftOutlined,
  SettingOutlined,
} from '@ant-design/icons';

// Icon的对应表
const IconMap = {
  HomeOutlined: <HomeOutlined />,
  CarryOutOutlined: <CarryOutOutlined />,
  smile: <SmileOutlined />,
  PicLeftOutlined: <PicLeftOutlined />,
  SettingOutlined: <SettingOutlined />,
  form: <FormOutlined />,
  user: <UserOutlined />,
};

// 转化Icon  string --> React.ReactNode
const loopMenuItem = (menus: MenuDataItem[]): MenuDataItem[] =>
  menus.map(({ icon, children, ...item }) => ({
    ...item,
    icon: icon && IconMap[icon as string],
    children: children && loopMenuItem(children),
  }));

const noMatch = (
  <Result
    status={403}
    title="403"
    subTitle="Sorry, you are not authorized to access this page."
    extra={
      <Button type="primary">
        <Link to="/user/login">Go Login</Link>
      </Button>
    }
  />
);
export type BasicLayoutProps = {
  breadcrumbNameMap: Record<string, MenuDataItem>;
  route: ProLayoutProps['route'] & {
    authority: string[];
  };
  settings: Settings;
  dispatch: Dispatch;
  menuData: MenuDataItem[]; // dymanic menu
} & ProLayoutProps;
export type BasicLayoutContext = { [K in 'location']: BasicLayoutProps[K] } & {
  breadcrumbNameMap: Record<string, MenuDataItem>;
};
/**
 * use Authorized check all menu item
 */

const menuDataRender = (menuList: MenuDataItem[]): MenuDataItem[] =>
  menuList.map((item) => {
    const localItem = {
      ...item,
      children: item.children ? menuDataRender(item.children) : undefined,
    };
    return Authorized.check(item.authority, localItem, null) as MenuDataItem;
  });

const defaultFooterDom = (
  <DefaultFooter
    copyright={`${new Date().getFullYear()} CrabShell`}
    links={
      []
    }
  />
);

const BasicLayout: React.FC<BasicLayoutProps> = (props) => {
  const {
    dispatch,
    children,
    settings,
    location = {
      pathname: '/',
    },
    menuData, // 菜单数据
    loading,
  } = props;

  const menuDataRef = useRef<MenuDataItem[]>([]);
  useEffect(() => {
    if (dispatch) {
      dispatch({
        type: 'user/fetchCurrent',
      });
      dispatch({
        type: 'menu/fetchMenu',
      });
    }
  }, []);
  /**
   * init variables
   */

  const handleMenuCollapse = (payload: boolean): void => {
    if (dispatch) {
      dispatch({
        type: 'global/changeLayoutCollapsed',
        payload,
      });
    }
  }; // get children authority

  const authorized = useMemo(
    () =>
      getMatchMenu(location.pathname || '/', menuDataRef.current).pop() || {
        authority: undefined,
      },
    [location.pathname],
  );
  const { formatMessage } = useIntl();
  return (
    <>
      <ProLayout
        logo={logo}
        formatMessage={formatMessage}
        {...props}
        {...settings}
        onCollapse={handleMenuCollapse}
        onMenuHeaderClick={() => history.push('/')}
        menuItemRender={(menuItemProps, defaultDom) => {
          if (
            menuItemProps.isUrl ||
            !menuItemProps.path ||
            location.pathname === menuItemProps.path
          ) {
            return defaultDom;
          }

          return <Link to={menuItemProps.path}>{defaultDom}</Link>;
        }}
        breadcrumbRender={(routers = []) => [
          {
            path: '/',
            breadcrumbName: formatMessage({
              id: 'menu.home',
            }),
          },
          ...routers,
        ]}
        itemRender={(route, params, routes, paths) => {
          const first = routes.indexOf(route) === 0;
          return first ? (
            <Link to={paths.join('/')}>{route.breadcrumbName}</Link>
          ) : (
            <span>{route.breadcrumbName}</span>
          );
        }}
        footerRender={() => defaultFooterDom}
        // menuDataRender={menuDataRender}
        // menuDataRender={() => menuData} // menuDataRender属性中传入菜单,这样是不对后台数据做任何处理,直接显示成菜单
        // menuDataRender={() => menuDataRender(menuData)} // menuDataRender传入菜单,是后台返回的数据,经过前端鉴权后的数据。如当前登录身份为user,后台返回的菜单中有一个权限为authority,不经过处理会直接显示,而前端处理一下menuDataRender(menuData)后,这个菜单就不会显示出来。
        menuDataRender={() => menuDataRender(loopMenuItem(menuData))} // 先处理图标,再做前端鉴权后的数据处理
        menu={{
          loading,
        }}
        rightContentRender={() => <RightContent />}
        postMenuData={(menuData) => {
          menuDataRef.current = menuData || [];
          return menuData || [];
        }}
      >
        <Authorized authority={authorized!.authority} noMatch={noMatch}>
          {children}
        </Authorized>
      </ProLayout>
      <SettingDrawer
        settings={settings}
        onSettingChange={(config) =>
          dispatch({
            type: 'settings/changeSetting',
            payload: config,
          })
        }
      />
    </>
  );
};

export default connect(({ global, settings, menu }: ConnectState) => ({
  collapsed: global.collapsed,
  settings,
  menuData: menu.menuData, // connect连接menu
  loading: menu.loading,
}))(BasicLayout);

尽管这样可以做到从服务器返回的菜单数据,导航栏也是按照后台返回的数据显示。但是用户还是可以通过直接输入链接去打开不显示在菜单栏的页面。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容