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