diff --git a/mock/demo/route.ts b/mock/demo/route.ts index 2eac15d..9ef924f 100644 --- a/mock/demo/route.ts +++ b/mock/demo/route.ts @@ -43,7 +43,7 @@ const adminRoute = [ export default [ { url: '/mock_api/getRoute', - timeout: 0, + timeout: 3000, method: 'post', response: ({ body }: { body: Recordable }) => { const { name } = body; diff --git a/src/App.tsx b/src/App.tsx index c40d969..396eb9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { ConfigProvider, Spin, theme } from 'antd'; +import { ConfigProvider, theme } from 'antd'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; import 'dayjs/locale/en'; @@ -15,6 +15,7 @@ import { useAppSelector } from './store/hooks'; import { getStorage } from './utils/storage'; import type { UseInfoType } from './server/useInfo'; import { initAsyncRoute } from './router/utils'; +import LayoutSpin from './components/LayoutSpin'; function App() { const { locale, color, themeMode } = useAppSelector( @@ -64,10 +65,10 @@ function App() { > {loading ? ( - + ) : ( // - }> + }> // diff --git a/src/Pages.tsx b/src/Pages.tsx index 6b5c700..6a9326e 100644 --- a/src/Pages.tsx +++ b/src/Pages.tsx @@ -4,34 +4,37 @@ import { useState, useEffect, memo } from 'react'; import { baseRouter, errorPage } from './router'; import { useAppSelector } from './store/hooks'; import { handlePowerRoute, handleRouteList } from './router/utils'; +import type { AsyncRouteType } from './store/modules/route'; + +// 为“/”根路由添加重定向 +const handleRedirect = (asyncRouter: AsyncRouteType[]) => { + const routerList = handleRouteList(handlePowerRoute(asyncRouter)); + if (routerList.length) { + routerList.push({ + path: '', + element: , + }); + } + return [...routerList, ...errorPage]; +}; + +const mapBaseRouter = (baseRouter: RouteObject[], asyncRouter: AsyncRouteType[]) => { + return baseRouter.map((i) => { + const routeItem = i; + if (routeItem.path === '/') { + routeItem.children = handleRedirect(asyncRouter); + } + return routeItem; + }); +}; const Pages = memo(() => { - const [route, setRoute] = useState(baseRouter); const asyncRouter = useAppSelector((state) => state.route.asyncRouter); - - // 为“/”根路由添加重定向 - const handleRedirect = () => { - const routerList = handleRouteList(handlePowerRoute(asyncRouter)); - if (routerList.length) { - routerList.push({ - path: '', - element: , - }); - } - return [...routerList, ...errorPage]; - }; + const [route, setRoute] = useState(mapBaseRouter(baseRouter, asyncRouter)); // 更新路由列表 useEffect(() => { - setRoute( - baseRouter.map((i) => { - const routeItem = i; - if (routeItem.path === '/') { - routeItem.children = handleRedirect(); - } - return routeItem; - }), - ); + setRoute(mapBaseRouter(baseRouter, asyncRouter)); }, [asyncRouter]); const routeElemt = createBrowserRouter(route); diff --git a/src/components/LayoutSpin/index.tsx b/src/components/LayoutSpin/index.tsx new file mode 100644 index 0000000..29914e8 --- /dev/null +++ b/src/components/LayoutSpin/index.tsx @@ -0,0 +1,12 @@ +import { Spin } from 'antd'; +import { memo } from 'react'; + +const LayoutSpin = memo(() => { + return ( +
+ +
+ ); +}); + +export default LayoutSpin; diff --git a/src/index.css b/src/index.css index 89c2278..096c311 100644 --- a/src/index.css +++ b/src/index.css @@ -6,9 +6,9 @@ body { padding: 0; } -:root { +#root { width: 100%; - height: 100%; + height: 100vh; transition: background 0.3s, width 0.3s cubic-bezier(0.2, 0, 0, 1) 0s; } @@ -18,6 +18,14 @@ ul { margin: 0; } +.supense-loading { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + .cursor { cursor: pointer; } diff --git a/src/layout/components/AppMain/AppMain.tsx b/src/layout/components/AppMain/AppMain.tsx index ac8ec96..f0fcfc7 100644 --- a/src/layout/components/AppMain/AppMain.tsx +++ b/src/layout/components/AppMain/AppMain.tsx @@ -1,17 +1,26 @@ import { Layout } from 'antd'; import { memo, Suspense } from 'react'; import { Outlet } from 'react-router-dom'; +import { KeepAlive } from './KeepAlive'; import { getAppMainStyle } from './style'; +import TabsPage from './TabsPage'; +import LayoutSpin from '@/components/LayoutSpin'; const { Content } = Layout; const AppMain = memo(() => { + const isKeepAlive = true; return ( +
- - - + {isKeepAlive ? ( + + ) : ( + }> + + + )}
); diff --git a/src/layout/components/AppMain/KeepAlive/index.tsx b/src/layout/components/AppMain/KeepAlive/index.tsx new file mode 100644 index 0000000..b01e739 --- /dev/null +++ b/src/layout/components/AppMain/KeepAlive/index.tsx @@ -0,0 +1,113 @@ +import type { RefObject, ReactNode } from 'react'; +import React, { Suspense, memo, useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useLocation, useOutlet } from 'react-router-dom'; +import { useAppSelector } from '@/store/hooks'; +import LayoutSpin from '@/components/LayoutSpin'; + +interface Props extends ComponentReactElement { + include?: Array; + maxLen?: number; +} +export const KeepAlive = memo(({ maxLen = 10 }: Props) => { + const ele = useOutlet(); + const location = useLocation(); + const activeName = location.pathname; + const multiTabs = useAppSelector((state) => state.route.multiTabs); + const levelAsyncRouter = useAppSelector((state) => state.route.levelAsyncRouter); + + const containerRef = useRef(null); + const [cacheReactNodes, setCacheReactNodes] = useState>( + [], + ); + + useEffect(() => { + if (!activeName) { + return; + } + const include = multiTabs.map((i) => i.key); + const levelRouter = levelAsyncRouter.map((i) => i.path); + setCacheReactNodes((reactNodes) => { + // 缓存超过上限的 + if (reactNodes.length >= maxLen) { + reactNodes = reactNodes.slice(0, 1); + } + // 添加 + const reactNode = reactNodes.find((res) => res.name === activeName); + if (!reactNode) { + reactNodes.push({ + name: activeName, + ele: ele, + }); + } else { + // 权限判断 + const nodeParom = reactNodes + .filter((i) => !levelRouter.includes(i.name)) + .map((i) => i.name); + const reactIndex = reactNodes.findIndex((res) => nodeParom.includes(res.name)); + if (reactIndex !== -1) reactNodes[reactIndex].ele = ele; + } + + // 缓存路由列表和标签页列表同步 + if (include) { + return reactNodes.filter((i) => include.includes(i.name)); + } + return reactNodes; + }); + }, [activeName, maxLen, multiTabs, levelAsyncRouter]); + + return ( + <> +
+ {cacheReactNodes.map((i) => { + return ( + + {i.ele} + + ); + })} + + ); +}); + +export interface ComponentReactElement { + children?: ReactNode | ReactNode[]; +} + +interface ComponentProps extends ComponentReactElement { + active: boolean; + name: string; + renderDiv: RefObject; +} + +export const Component: React.FC = ({ active, children, name, renderDiv }) => { + const [targetElement] = useState(() => document.createElement('div')); + const activatedRef = useRef(false); + activatedRef.current = activatedRef.current || active; + + useEffect(() => { + if (active) { + renderDiv.current?.appendChild(targetElement); + } else { + try { + renderDiv.current?.removeChild(targetElement); + // eslint-disable-next-line no-empty + } catch (e) {} + } + }, [active, name, renderDiv, targetElement]); + + useEffect(() => { + targetElement.setAttribute('id', name); + }, [name, targetElement]); + + return ( + }> + {activatedRef.current && createPortal(children, targetElement)} + + ); +}; diff --git a/src/layout/components/AppMain/TabsPage/index.tsx b/src/layout/components/AppMain/TabsPage/index.tsx new file mode 100644 index 0000000..7e83791 --- /dev/null +++ b/src/layout/components/AppMain/TabsPage/index.tsx @@ -0,0 +1,82 @@ +import { Tabs } from 'antd'; +import { memo, useEffect, useMemo } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { findRouteByPath, routeListToMenu } from '@/router/utils'; +import { setStoreMultiTabs } from '@/store/modules/route'; +import defaultRoute from '@/router/modules'; + +const TabsPage = memo(() => { + const location = useLocation(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const menuList = routeListToMenu(defaultRoute); + const asyncRouter = useAppSelector((state) => state.route.asyncRouter); + const multiTabs = useAppSelector((state) => state.route.multiTabs); + + const tabsItem = useMemo(() => { + return multiTabs.map((i) => { + const routeBy = findRouteByPath(i.key, menuList); + return { + key: i.key, + label: routeBy?.label, + }; + }); + }, [multiTabs]); + + const handleTabsList = (pathName: string, type: 'add' | 'delete') => { + const oldKeyAliveList = [...multiTabs]; + + const tabIndex = oldKeyAliveList.findIndex((i) => i.key === pathName); + switch (type) { + case 'add': + if (tabIndex === -1) { + oldKeyAliveList.push({ key: pathName }); + } + break; + case 'delete': + if (tabIndex !== -1) oldKeyAliveList.splice(tabIndex, 1); + break; + default: + break; + } + + dispatch(setStoreMultiTabs(oldKeyAliveList)); + }; + + const onEdit = ( + targetKey: React.MouseEvent | React.KeyboardEvent | string, + action: 'add' | 'remove', + ) => { + if (action === 'remove') { + const muIndex = multiTabs.findIndex((i) => i.key === targetKey); + if (muIndex === multiTabs.length - 1) navigate(multiTabs[muIndex - 1].key); + else navigate(multiTabs[multiTabs.length - 1].key); + handleTabsList(targetKey as string, 'delete'); + } + }; + + useEffect(() => { + if (location.pathname === '/') { + if (asyncRouter.length) navigate(asyncRouter[0].path); + return; + } + handleTabsList(location.pathname, 'add'); + }, [location.pathname]); + + return ( + 1 ? 'editable-card' : 'card'} + onChange={(key) => navigate(key)} + onEdit={onEdit} + tabBarStyle={{ + margin: 0, + }} + items={tabsItem} + /> + ); +}); + +export default TabsPage; diff --git a/src/layout/components/AppMain/style.tsx b/src/layout/components/AppMain/style.tsx index 7248c7f..d796a38 100644 --- a/src/layout/components/AppMain/style.tsx +++ b/src/layout/components/AppMain/style.tsx @@ -2,10 +2,13 @@ import type { CSSObject } from '@emotion/react'; export const getAppMainStyle = (): CSSObject => { return { + display: 'flex', + flexDirection: 'column', ['.main-content']: { padding: 12, height: '100%', overflowY: 'auto', + position: 'relative', }, }; }; diff --git a/src/router/utils.tsx b/src/router/utils.tsx index 5b09275..6de26d0 100644 --- a/src/router/utils.tsx +++ b/src/router/utils.tsx @@ -5,8 +5,8 @@ import { lazy } from 'react'; import { cloneDeep } from 'lodash-es'; import defaultRoute from './modules'; import type { MenuItem, RouteList } from '@/router/route'; -import type { RouteDataItemType } from '@/server/route'; import { getRouteApi } from '@/server/route'; +import type { AsyncRouteType } from '@/store/modules/route'; import { setStoreAsyncRouter } from '@/store/modules/route'; import store from '@/store'; const ErrorElement = lazy(() => import('@/views/core/error/ErrorElement')); @@ -22,7 +22,7 @@ export async function initAsyncRoute(power: string) { } export function handlePowerRoute( - dataRouter: RouteDataItemType[], + dataRouter: AsyncRouteType[], routerList: RouteList[] = defaultRoute, ) { const newRouteList: RouteList[] = []; @@ -138,3 +138,56 @@ export function findRouteByPath(path: Key, routes: MenuItem[]): MenuItem | null return null; } } + +// 拼接路径 伪path resolve +function pathResolve(...paths: string[]) { + let resolvePath = ''; + let isAbsolutePath = false; + for (let i = paths.length - 1; i > -1; i--) { + const path = paths[i]; + if (isAbsolutePath) { + break; + } + if (!path) { + continue; + } + resolvePath = path + '/' + resolvePath; + isAbsolutePath = path.charCodeAt(0) === 47; + } + if (/^\/+$/.test(resolvePath)) { + resolvePath = resolvePath.replace(/(\/+)/, '/'); + } else { + resolvePath = resolvePath + .replace(/(?!^)\w+\/+\.{2}\//g, '') + .replace(/(?!^)\.\//g, '') + .replace(/\/+$/, ''); + } + return resolvePath; +} + +// 设置完整路由path, +export function setUpRoutePath(routeList: AsyncRouteType[], pathName = '') { + for (const node of routeList) { + if (pathName) { + node.path = pathResolve(pathName, node.path || ''); + } + if (node.children && node.children.length) { + setUpRoutePath(node.children, node.path); + } + } + return routeList; +} + +// 扁平路由 +export function formatFlatteningRoutes(routesList: AsyncRouteType[]) { + if (routesList.length === 0) return routesList; + let hierarchyList = routesList; + for (let i = 0; i < hierarchyList.length; i++) { + if (hierarchyList[i].children) { + hierarchyList = hierarchyList + .slice(0, i + 1) + .concat(hierarchyList[i].children || [], hierarchyList.slice(i + 1)); + } + } + return hierarchyList; +} diff --git a/src/server/route.ts b/src/server/route.ts index 4f02763..6933069 100644 --- a/src/server/route.ts +++ b/src/server/route.ts @@ -1,3 +1,4 @@ +import type { AsyncRouteType } from '@/store/modules/route'; import { deffHttp } from '@/utils/axios'; enum Api { @@ -8,11 +9,5 @@ interface Param { name: string; } -export interface RouteDataItemType { - path: string; - id: string; - children: RouteDataItemType[]; -} - export const getRouteApi = (data: Param) => - deffHttp.post({ url: Api.ROUTE_LIST, data }); + deffHttp.post({ url: Api.ROUTE_LIST, data }); diff --git a/src/store/modules/route.ts b/src/store/modules/route.ts index d8bb35d..7101642 100644 --- a/src/store/modules/route.ts +++ b/src/store/modules/route.ts @@ -1,25 +1,44 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import type { RouteDataItemType } from '@/server/route'; +import { formatFlatteningRoutes, setUpRoutePath } from '@/router/utils'; + +export interface AsyncRouteType { + path: string; + id: string; + children: AsyncRouteType[]; +} + +export interface MultiTabsType { + // label: React.ReactNode; + key: string; +} interface RouteState { - asyncRouter: RouteDataItemType[]; + asyncRouter: AsyncRouteType[]; + levelAsyncRouter: AsyncRouteType[]; + multiTabs: MultiTabsType[]; } const initialState: RouteState = { asyncRouter: [], + levelAsyncRouter: [], + multiTabs: [], }; export const routeSlice = createSlice({ name: 'route', initialState, reducers: { - setStoreAsyncRouter: (state, action: PayloadAction) => { + setStoreAsyncRouter: (state, action: PayloadAction) => { state.asyncRouter = action.payload; + state.levelAsyncRouter = formatFlatteningRoutes(setUpRoutePath(action.payload)); + }, + setStoreMultiTabs: (state, action: PayloadAction) => { + state.multiTabs = action.payload; }, }, }); // 每个 case reducer 函数会生成对应的 Action creators -export const { setStoreAsyncRouter } = routeSlice.actions; +export const { setStoreAsyncRouter, setStoreMultiTabs } = routeSlice.actions; export default routeSlice.reducer;