react-admin是一个开箱即用的中大型后台管理系统,不仅只有前端解决方案,更是提供了基于nestjs的后端解决方案。
路由
路由分为静态路由和动态路由,目前项目使用的都是静态路由,等后端开发完成之后会加入动态路由,根据当前登录的用户分配不同的路由,其实就是路由权限-页面级权限。
路由--->菜单
目前的路由是写在前端项目里并根据模块来划分的。路径位于: src/router。这边文章主要是写通过路由来生成侧边栏
扩展路由类型
react-router的路由对象不满足现有业务,需要扩展路由类型,使得在生成菜单时更方便。
// src/types/custom-types.d.ts
export interface IRouteObjectMeta {
auth?: boolean; // 是否需要权限
title?: string; // 国际化的key
menu?: boolean; // 是否是菜单
icon?: string; // 系统icon是使用iconify来管理的,所以这里是iconify的icon值。如:carbon:information 或是使用本地icon ra-icon:esc,本地icon查看项目icon文档。
order?: number; // 菜单排序 值越小越排在前面
hidden: false, // 是否隐藏 是否在侧边栏展示
openMode?: 'iframe' | 'newBrowserTab' | 'router'; // 菜单打开方式 iframe-使用iframe嵌入系统 newBrowserTab-使用新的浏览器标签打开 router-使用路由打开
type?: 菜单类型,// 目前没有使用到,为了后面扩展先写上,比如菜单不止在侧边栏展示,还有快捷导航,或是首页导航
}
export type IRouteObject = RouteObject & {
meta?: IRouteObjectMeta;
children?: (RouteObject & IRouteObjectMeta)[]; // 重写children
};
构造路由对象并排序
在编写好模块的路由后,通过vite的import.meta.glob的来解析模块,并返回一个路由数组
// src/router/utils/index.ts
import { IRouteObject } from '@/types/custom-types';
export function getRoutes() {
const routes: IRouteObject[] = [];
// * 导入所有route
const metaRoutes: Record<string, any> = import.meta.glob('../modules/*.tsx', {
eager: true,
});
Object.keys(metaRoutes).forEach((item) => {
Object.keys(metaRoutes[item]).forEach((key: any) => {
routes.push(...metaRoutes[item][key]);
});
});
// 根据order排序,升序排列,值越小越在前面
routes.sort((a, b) => (a.meta?.order || 0) - (b.meta?.order || 0));
return routes;
}
将解析后的路由数组组装成最后的路由数组
这里需要注意每个父路由下都会有一个特殊的路由,用于默认跳转
- 如果用户直接在浏览器输入一个父路由路径,此时页面不会有任何展示
- 嵌套二三级路由下如果父路由没有element并且没有默认路由或是父路由没有使用
outlet,这些情况下子路由都不会有任何展示
基于以上两种情况,我们需要为父路由添加一个默认跳转,并且这个跳转包含outlet,所以封装了一个RedirectRouteView组件
// src/router/RedirectRouteView.tsx
/**
* 此组件用于父路由默认跳转
*/
import { Navigate, Outlet } from 'react-router-dom';
type RedirectRouteViewProps = {
to: string;
};
const RedirectRouteView = ({ to }: RedirectRouteViewProps) => {
return (
<>
<Navigate to={to} replace />
<Outlet />
</>
);
};
export default RedirectRouteView;
<RedirectRouteView />组件使用
import { lazy } from 'react';
import LazyLoadComponent from '@/components/LazyLoadComponent';
import RedirectRouteView from '../RedirectRouteView';
import type { IRouteObject } from '@/types/custom-types';
// 嵌套菜单
const nestedMenuRouter: IRouteObject[] = [
{
path: '/nestedMenu',
meta: {
title: 'nestedMenu',
icon: 'ant-design:menu-outlined',
auth: true,
menu: true,
hidden: false,
openMode: 'router',
order: 8,
},
children: [
// 注意这里,index: true就是父路由的默认跳转,然后他的element就是我们封装的RedirectRouteView组件
{
index: true,
element: <RedirectRouteView to="/nestedMenu/menu1" />,
},
{
path: 'menu1',
element: (
<LazyLoadComponent
Component={lazy(() => import('@/pages/nestedMenu/menu1'))}
/>
),
meta: {
auth: true,
menu: true,
hidden: false,
openMode: 'router',
title: 'nestedMenu1',
icon: 'ant-design:menu-outlined',
},
},
{
path: 'menu2',
meta: {
auth: true,
menu: true,
hidden: false,
openMode: 'router',
title: 'nestedMenu2',
icon: 'ri:menu-fold-4-line',
},
children: [
{
index: true,
element: <RedirectRouteView to="/nestedMenu/menu2/menu2-1" />,
},
{
path: 'menu2-1',
index: true,
element: (
<LazyLoadComponent
Component={lazy(
() => import('@/pages/nestedMenu/menu2/menu2-1'),
)}
/>
),
meta: {
auth: true,
menu: true,
hidden: false,
openMode: 'router',
title: 'nestedMenu2-1',
icon: 'ant-design:menu-outlined',
},
},
],
},
{
path: 'menu3',
meta: {
auth: true,
menu: true,
hidden: false,
openMode: 'router',
title: 'nestedMenu3',
icon: 'ri:menu-unfold-4-line',
},
children: [
{
index: true,
element: <RedirectRouteView to="/nestedMenu/menu3/menu3-1" />,
},
{
path: 'menu3-1',
index: true,
element: (
<LazyLoadComponent
Component={lazy(
() => import('@/pages/nestedMenu/menu3/menu3-1'),
)}
/>
),
meta: {
auth: true,
menu: true,
hidden: false,
openMode: 'router',
title: 'nestedMenu3-1',
icon: 'ant-design:menu-outlined',
},
},
{
path: 'menu3-2',
meta: {
auth: true,
menu: true,
hidden: false,
openMode: 'router',
title: 'nestedMenu3-2',
icon: 'ri:menu-unfold-4-line',
},
children: [
{
index: true,
element: (
<RedirectRouteView to="/nestedMenu/menu3/menu3-2/menu3-2-1" />
),
},
{
path: 'menu3-2-1',
element: (
<LazyLoadComponent
Component={lazy(
() =>
import('@/pages/nestedMenu/menu3/menu3-2/menu3-2-1'),
)}
/>
),
meta: {
auth: true,
menu: true,
hidden: false,
openMode: 'router',
title: '菜单3-2-1',
icon: 'ant-design:menu-outlined',
},
},
],
},
],
},
],
},
];
export default nestedMenuRouter;
最终生成的routes
// src/router/routes.tsx
import { lazy } from 'react';
import { Navigate } from 'react-router-dom';
import Error404 from '@/components/Error/404';
import LazyLoadComponent from '@/components/LazyLoadComponent';
import { getRoutes } from './utils';
import Layout from '@/layouts/index';
import { IRouteObject } from '@/types/custom-types';
const routeArray = getRoutes();
const routes: IRouteObject[] = [
{
path: '/login',
element: (
<LazyLoadComponent Component={lazy(() => import('@/pages/login'))} />
),
meta: {
menu: false,
},
},
{
path: '/',
element: <Layout />,
meta: {
menu: false,
},
children: [
{
index: true,
element: <Navigate to="/overview/workspace" replace />,
meta: {
menu: false,
},
},
...routeArray,
{
path: '*',
element: <Error404 />,
meta: {
menu: false,
},
},
],
},
];
export default routes;
侧边栏
侧边栏主要是通过递归遍历路由来过滤生成的
// src/layouts/hooks/useMenu.tsx
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import Icon from '@/components/RaIcon';
import type { IRouteObject, MenuItem } from '@/types/custom-types';
import routes from '@/router/routes';
import useGlobalStore from '@/store';
import useMenuStore from '@/store/menu';
import { dfs } from '@/utils';
function formatRoutes({
routes,
menuItems,
parentRoute,
parentMenu,
}: {
routes: IRouteObject[]; // 路由数组也会是路由嵌套children
menuItems: MenuItem[]; // 最后返回的菜单,嵌套的时候是对应的children
parentMenu?: MenuItem; // 父菜单
parentRoute?: IRouteObject; // 父路由
}) {
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
const meta = route.meta;
let menuItem: MenuItem | null = null;
// 过滤不是菜单的路由
if (meta?.menu) {
menuItem = {
title: meta.title as string,
label: meta.title as string,
hidden: !!meta.hidden,
// 如果是菜单用父菜单的key加当前路由的path组成,这样既能是唯一的也是当前菜单的完整路由
key: parentRoute?.meta?.menu
? ((parentMenu?.key + '/' + route.path) as string)
: (route.path as string),
icon: (
<Icon
icon={meta?.icon || 'ant-design:appstore-outlined'}
wrapClassName="leading-none mr-[4]"
/>
),
// 加上这个是为了全局搜索和tabs缓存在本地时使用,localstorage不支持组件缓存所以缓存字符串
['data-icon']: meta?.icon || 'ant-design:appstore-outlned',
};
}
// 有嵌套路由递归遍历
if (route.children && route.children.length > 0) {
menuItem = menuItem || { children: [], key: '', label: '' };
menuItem.children = [];
formatRoutes({
routes: route.children,
menuItems: menuItem.children,
parentMenu: menuItem,
parentRoute: route,
});
}
if (menuItem) {
menuItems?.push(menuItem);
}
}
}
function useMenu() {
const { t } = useTranslation();
const appLanguage = useGlobalStore((state) => state.appLanguage);
// 将菜单数据存入全局store
const { setFlatMenuItems, setMenuItems } = useMenuStore(
useShallow((state) => ({
setMenuItems: state.setMenuItems,
setFlatMenuItems: state.setFlatMenuItems,
})),
);
// 菜单缓存
const menuItemsRef = useRef<MenuItem[]>([]);
// 铺平的菜单,tabs及全局搜索用到
const flatMenuItemsRef = useRef<MenuItem[]>([]);
useEffect(() => {
const menuItems: MenuItem[] = [];
formatRoutes({
routes,
menuItems,
});
menuItemsRef.current = menuItems[0].children as MenuItem[];
flatMenuItemsRef.current = dfs(menuItemsRef.current);
}, [routes]);
useEffect(() => {
const filteredMenuItems: MenuItem[] = [];
function translate(menuItems: MenuItem[], filteredMenuItems: MenuItem[]) {
for (let i = 0; i < menuItems.length; i++) {
const item = menuItems[i];
// label是国际化后的文本
item.label = t(`menu.${item.title}`);
const cloneItem = { ...item };
if (item.children && item.children.length > 0) {
const cloneItemChildren: MenuItem[] = [];
translate(item.children, cloneItemChildren);
cloneItem.children = cloneItemChildren;
}
if (!cloneItem.hidden) {
filteredMenuItems.push(cloneItem);
}
}
}
translate(menuItemsRef.current, filteredMenuItems);
setMenuItems(filteredMenuItems);
const newFlatMenuItems = flatMenuItemsRef.current.map((i) => ({
...i,
label: t(`menu.${i.title}`),
}));
setFlatMenuItems(newFlatMenuItems);
}, [appLanguage]);
}
export default useMenu;
- 根据菜单在Sider组件中使用
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu } from 'antd';
import { useShallow } from 'zustand/react/shallow';
import AppLogo from '@/components/app/AppLogo';
import { getLevelKeys } from '../../utils/utils';
import styles from './index.module.css';
import type { LevelKeysProps } from '../../utils/utils';
import type { MenuProps } from 'antd/lib';
import useMenuStore from '@/store/menu';
const { Sider } = Layout;
const AppSider = () => {
const navigate = useNavigate();
const location = useLocation();
const { collapsed, menuItems } = useMenuStore(
useShallow((state) => ({
menuItems: state.menuItems,
collapsed: state.collapsed,
})),
);
const [openKeys, setOpenKeys] = useState<string[]>([]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
// 监听路由变化 设置侧边栏展开选中
useEffect(() => {
setSelectedKeys([location.pathname]);
const keysArr = location.pathname.split('/').filter((i) => i);
const keys: string[] = [];
// 根据pathname生成keys
keysArr.reduce((prev: string, current: string) => {
const path = prev ? `${prev}/${current}` : `/${current}`;
keys.push(path);
return path;
}, '');
setOpenKeys(keys);
}, [location.pathname]);
const levelKeys = getLevelKeys(menuItems as LevelKeysProps[]);
// 菜单展开时关闭其他已经展开的菜单
const onOpenChange: MenuProps['onOpenChange'] = (allOpenKeys) => {
const currentOpenKey = allOpenKeys.find(
(key) => openKeys.indexOf(key) === -1,
);
// open
if (currentOpenKey !== undefined) {
const repeatIndex = allOpenKeys
.filter((key) => key !== currentOpenKey)
.findIndex((key) => levelKeys[key] === levelKeys[currentOpenKey]);
setOpenKeys(
allOpenKeys
// remove repeat key
.filter((_, index) => index !== repeatIndex)
// remove current level all child
.filter((key) => levelKeys[key] <= levelKeys[currentOpenKey]),
);
} else {
// close
setOpenKeys(allOpenKeys);
}
};
const handleItemClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
};
return (
<Sider
className={styles['app-layout-sider']}
trigger={null}
collapsible
collapsed={collapsed}
collapsedWidth={48}
>
<AppLogo
showTitle={!collapsed}
style={{
color: 'var(--ant-color-text)',
backgroundColor: 'var(--ant-color-bg-container)',
}}
/>
<Menu
mode="inline"
defaultSelectedKeys={['1']}
items={menuItems}
onClick={handleItemClick}
selectedKeys={selectedKeys}
openKeys={openKeys}
onOpenChange={onOpenChange}
className="overflow-y-auto flex-1"
/>
</Sider>
);
};
export default AppSider;