first commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import {Breadcrumb, type BreadcrumbProps} from "antd";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {HomeOutlined} from "@ant-design/icons";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useLocation} from "react-router";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import {buildBreadcrumbMap, findMenuByPath} from "@/layout/utils.ts";
|
||||
|
||||
const defaultBreadcrumb: BreadcrumbProps['items'] = [
|
||||
{
|
||||
title: <HomeOutlined />,
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const BreadcrumbRender = () => {
|
||||
const {t} = useTranslation();
|
||||
const location = useLocation();
|
||||
const { menus } = useLayoutContext();
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<BreadcrumbProps['items']>(defaultBreadcrumb);
|
||||
|
||||
const breadcrumbMap = useMemo(() => buildBreadcrumbMap(menus), [menus]);
|
||||
|
||||
useEffect(() => {
|
||||
const path = location.pathname;
|
||||
const menu = findMenuByPath(menus, path);
|
||||
if(menu && menu.key) {
|
||||
const breadcrumbs = breadcrumbMap[menu.key];
|
||||
if(breadcrumbs) {
|
||||
setBreadcrumbItems([
|
||||
{
|
||||
title: <HomeOutlined />,
|
||||
},
|
||||
...breadcrumbs.map(item => ({
|
||||
title: (
|
||||
<>
|
||||
{item.icon && <IconFont name={item.icon} />}
|
||||
<span>{item.local ? t(item.local) : item.title}</span>
|
||||
</>
|
||||
),
|
||||
}))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBreadcrumbItems(defaultBreadcrumb)
|
||||
}, [t, location.pathname, breadcrumbMap]);
|
||||
|
||||
return (
|
||||
<Breadcrumb items={breadcrumbItems}/>
|
||||
)
|
||||
}
|
||||
|
||||
export default BreadcrumbRender;
|
||||
@@ -0,0 +1,72 @@
|
||||
import IconFont from "@/components/IconFont";
|
||||
import MenuRender from "@/layout/MenuRender.tsx";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {theme} from "antd";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import React from "react";
|
||||
import type {IMenus} from "@/domain/iSysRule.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
const {useToken} = theme;
|
||||
|
||||
const ColumnsMenu: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const {token} = useToken();
|
||||
const navigate = useNavigate();
|
||||
const {menus, parentKey, collapsed, setParentKey, setSelectKey} = useLayoutContext();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
|
||||
const onMenuChange = (menu: IMenus) => {
|
||||
if(!menu.key) return;
|
||||
setParentKey(menu.key);
|
||||
if(menu.type === 'menu') {
|
||||
return;
|
||||
}
|
||||
setSelectKey([menu.key]);
|
||||
// 外链路由
|
||||
if(menu.type === 'route' && menu.link) {
|
||||
window.open(menu.path, '_blank');
|
||||
return;
|
||||
}
|
||||
if(menu.type === 'route' && menu.path) {
|
||||
navigate(menu.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex h-full'}>
|
||||
<div
|
||||
className={'box-border shrink-0 h-full overflow-auto'}
|
||||
style={{
|
||||
borderRight: !collapsed ? "1px solid " + themeConfig.colorBorder : 'none',
|
||||
width: 79
|
||||
}}
|
||||
>
|
||||
{/* 侧栏一级菜单 */}
|
||||
{menus.filter(item => item.hidden).map(rule => (
|
||||
<div
|
||||
key={rule.key}
|
||||
style={{
|
||||
backgroundColor: parentKey === rule.key ? token.colorPrimaryBg : 'transparent',
|
||||
color: parentKey === rule.key ? token.colorPrimary : themeConfig.siderColor,
|
||||
}}
|
||||
className={"flex items-center justify-center flex-col p-2 mb-2 pt-3 pb-3 cursor-pointer"}
|
||||
onClick={() => onMenuChange(rule)}
|
||||
>
|
||||
<IconFont name={rule.icon} style={{ fontSize: 18 }}/>
|
||||
<span className={"mt-1 truncate w-full text-center"}>{rule.local ? t(rule.local) : rule.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ !collapsed && (
|
||||
<div className={'flex-1 h-full overflow-y-auto'}>
|
||||
<MenuRender />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColumnsMenu;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import {Layout} from "antd";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
|
||||
const {Footer} = Layout;
|
||||
|
||||
const FooterRender: React.FC = () => {
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
{themeConfig.fixedFooter &&
|
||||
<div className={"h-10"}></div>
|
||||
}
|
||||
<Footer
|
||||
className={
|
||||
(themeConfig.fixedFooter ? 'sticky' : 'relative') +
|
||||
" z-10 w-full bottom-0 pt-2.5 pb-2.5"
|
||||
}
|
||||
style={{
|
||||
borderTop: '1px solid ' + themeConfig.colorBorder,
|
||||
}}
|
||||
>
|
||||
<div className={"flex items-center justify-center w-full"}>
|
||||
Xin Admin ©{currentYear} Created by xiaoliu
|
||||
</div>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterRender;
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Button, ConfigProvider, Layout, Menu, type MenuProps, type ThemeConfig} from "antd";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import HeaderRightRender from "@/layout/HeaderRightRender";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import MenuRender from "@/layout/MenuRender.tsx";
|
||||
import {MenuFoldOutlined, MenuUnfoldOutlined} from "@ant-design/icons";
|
||||
import BreadcrumbRender from "@/layout/BreadcrumbRender.tsx";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import useMobile from "@/hooks/useMobile";
|
||||
import {useNavigate} from "react-router";
|
||||
import {findMenuByKey} from "@/layout/utils.ts";
|
||||
|
||||
const {Header} = Layout;
|
||||
|
||||
const HeaderRender: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const navigate = useNavigate();
|
||||
const logo = useGlobalStore(state => state.logo);
|
||||
const title = useGlobalStore(state => state.title);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const { menus, parentKey, collapsed, setCollapsed, setParentKey, setSelectKey } = useLayoutContext();
|
||||
|
||||
const theme: ThemeConfig = {
|
||||
token: { colorTextBase: themeConfig.headerColor },
|
||||
components: {
|
||||
Menu: {
|
||||
activeBarBorderWidth: 0,
|
||||
itemBg: 'transparent',
|
||||
}
|
||||
}
|
||||
}
|
||||
const [mixMenu, setMixMenu] = useState<MenuProps['items']>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setMixMenu(menus.filter(item => item.hidden).map(item => ({
|
||||
label: item.local ? t(item.local) : item.name,
|
||||
icon: item.icon ? <IconFont name={item.icon}/> : false,
|
||||
key: item.key!,
|
||||
})));
|
||||
}, [menus, t]);
|
||||
|
||||
const onMenuChange = (key: string) => {
|
||||
const menu = findMenuByKey(menus, key);
|
||||
if(!menu || !menu.key) return;
|
||||
setParentKey(menu.key);
|
||||
if(menu.type === 'menu') {
|
||||
return;
|
||||
}
|
||||
setSelectKey([menu.key]);
|
||||
if(menu.type === 'route' && menu.link) {
|
||||
window.open(menu.path, '_blank');
|
||||
return;
|
||||
}
|
||||
if(menu.type === 'route' && menu.path) {
|
||||
navigate(menu.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={theme}>
|
||||
{ isMobile ? (
|
||||
<Header className={"flex sticky z-1 top-0 backdrop-blur-xs justify-between items-center"}>
|
||||
<div className={"flex items-center"}>
|
||||
<img className={"w-9 mr-5"} src={logo} alt="logo"/>
|
||||
<span className={"font-semibold text-[20px] mr-2"}>{title}</span>
|
||||
</div>
|
||||
<HeaderRightRender/>
|
||||
</Header>
|
||||
) : (
|
||||
<Header
|
||||
className={"flex sticky z-1 top-0 backdrop-blur-xs"}
|
||||
style={{borderBottom: "1px solid " + themeConfig.colorBorder}}
|
||||
>
|
||||
<div className={"flex items-center relative"}>
|
||||
<img className={"h-8"} src={logo} alt="logo"/>
|
||||
<span className={"font-semibold text-[20px] ml-5 mr-5"}>{title}</span>
|
||||
{/* 侧边栏开关 */}
|
||||
{layout !== 'top' && (
|
||||
<Button
|
||||
type={'text'}
|
||||
className={'text-[16px] mr-2.5'}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{ collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/> }
|
||||
</Button>
|
||||
)}
|
||||
{/* 面包屑 */}
|
||||
{['columns', 'side'].includes(layout) && <BreadcrumbRender/> }
|
||||
</div>
|
||||
<div className={"overflow-hidden flex-1"}>
|
||||
{/* 顶部菜单 */}
|
||||
{ layout == 'top' && <MenuRender /> }
|
||||
{/* 混合布局模式下的顶部菜单 */}
|
||||
{ layout == 'mix' && (
|
||||
<Menu
|
||||
className={"border-b-0 w-full"}
|
||||
mode="horizontal"
|
||||
items={mixMenu}
|
||||
onSelect={(info) => onMenuChange(info.key)}
|
||||
selectedKeys={[parentKey!]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HeaderRightRender/>
|
||||
</Header>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderRender;
|
||||
@@ -0,0 +1,131 @@
|
||||
import {Avatar, Button, Dropdown, Empty, Input, type MenuProps, message, Modal, Space, theme} from "antd";
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
EnterOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
GithubOutlined,
|
||||
HomeOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
VerticalLeftOutlined
|
||||
} from "@ant-design/icons";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import useAuthStore from "@/stores/user";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useState} from "react";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import useMobile from "@/hooks/useMobile";
|
||||
|
||||
const {useToken} = theme;
|
||||
|
||||
const HeaderLeftRender = () => {
|
||||
const {token} = useToken();
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate()
|
||||
const themeDrawer = useGlobalStore(state => state.themeDrawer);
|
||||
const setThemeDrawer = useGlobalStore(state => state.setThemeDrawer);
|
||||
const userInfo = useAuthStore(state => state.userinfo);
|
||||
const logout = useAuthStore(state => state.logout);
|
||||
const isMobile = useMobile();
|
||||
const [fullscreen, setFullscreen] = useState<boolean>(false);
|
||||
const [searchOpen, setSearch] = useState<boolean>(false);
|
||||
|
||||
/** 全屏按钮操作事件 */
|
||||
const onFullscreenClick = () => {
|
||||
/* 获取 documentElement (<html>) 以全屏显示页面 */
|
||||
const elem = document.documentElement;
|
||||
/* 全屏查看 */
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().then(() => {
|
||||
setFullscreen(false);
|
||||
});
|
||||
} else {
|
||||
elem.requestFullscreen().then(() => {
|
||||
setFullscreen(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const userItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: t('layout.profile'),
|
||||
icon: <UserOutlined/> ,
|
||||
onClick: () => navigate('/user/profile')
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('layout.logout'),
|
||||
icon: <VerticalLeftOutlined />,
|
||||
onClick: async () => {
|
||||
logout().then(() => {
|
||||
message.success(t('layout.logoutSuccess'))
|
||||
window.location.href = '/login';
|
||||
}).catch(() => {
|
||||
message.error(t('layout.logoutFailed'))
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
closable={false}
|
||||
open={searchOpen}
|
||||
footer={null}
|
||||
style={{top: 40, width: 600}}
|
||||
styles={{body: {maxHeight: '80vh'}, container: {padding: 0}}}
|
||||
onCancel={() => setSearch(false)}
|
||||
>
|
||||
<div className={'p-5'}>
|
||||
<Input size="large" placeholder={t('layout.searchPlaceholder')} prefix={<SearchOutlined/>}/>
|
||||
<div className={'mt-5'}>
|
||||
<Empty/>
|
||||
{/* TODO 可以帮忙实现菜单栏搜索 */}
|
||||
</div>
|
||||
</div>
|
||||
<Space className={'mt-5 flex align-center pl-5 pr-5 pt-2.5 pb-2.5'}
|
||||
style={{borderTop: '1px solid ' + token.colorBorder}}>
|
||||
<EnterOutlined/> <span className={'mr-4'}>{t('layout.searchConfirm')}</span>
|
||||
<ArrowUpOutlined/><ArrowDownOutlined/> <span className={'mr-4'}>{t('layout.searchSwitch')}</span>
|
||||
Esc <span className={'mr-2'}>{t('layout.searchClose')}</span>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Space>
|
||||
{ !isMobile && (
|
||||
<>
|
||||
<Button icon={<HomeOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://xin-admin.com')}/>
|
||||
<Button icon={<GithubOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://github.com/xin-admin/xin-admin-ui')}/>
|
||||
<Button icon={<SearchOutlined/>} size={'large'} type={'text'} onClick={() => setSearch(true)}/>
|
||||
<Button
|
||||
onClick={onFullscreenClick}
|
||||
icon={fullscreen ? <FullscreenExitOutlined/> : <FullscreenOutlined/>}
|
||||
size={"large"}
|
||||
type={'text'}
|
||||
/>
|
||||
<LanguageSwitcher size={"large"} type={'text'} />
|
||||
<Button onClick={() => setThemeDrawer(!themeDrawer)} icon={<SettingOutlined/>} size={"large"} type={'text'}/>
|
||||
</>
|
||||
)}
|
||||
{userInfo ?
|
||||
<Dropdown menu={{items: userItems}}>
|
||||
<Button size={"large"} type={'text'}>
|
||||
<div>{userInfo.nickname || userInfo.username}</div>
|
||||
<Avatar src={userInfo.avatar_url ? userInfo.avatar_url : null} size={'small'}/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
: null
|
||||
}
|
||||
</Space>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderLeftRender
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, {createContext, useContext, useEffect, useState} from "react";
|
||||
import {useLocation} from "react-router";
|
||||
import {menu} from "@/api/system/sys_user";
|
||||
import type {IMenus} from "@/domain/iSysRule.ts";
|
||||
import {getMenuParentKeys} from "@/layout/utils.ts";
|
||||
|
||||
interface LayoutContextType {
|
||||
menus: IMenus[];
|
||||
parentKey: string | undefined;
|
||||
selectKey: string[];
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
setParentKey: (key: string) => void;
|
||||
setSelectKey: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextType | null>(null);
|
||||
|
||||
export const LayoutProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const [menus, setMenus] = useState<IMenus[]>([]);
|
||||
const [parentKey, setParentKey] = useState<string>();
|
||||
const [selectKey, setSelectKey] = useState<string[]>([]);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// 获取菜单数据
|
||||
useEffect(() => {
|
||||
menu().then(({ data }) => {
|
||||
const items = data.data!.menus;
|
||||
setMenus(items);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 根据 URL 恢复菜单状态
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
const ancestors = getMenuParentKeys(menus, pathname);
|
||||
if(ancestors && ancestors.length) {
|
||||
setSelectKey(ancestors);
|
||||
setParentKey(ancestors[0]);
|
||||
}
|
||||
}, [menus]);
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{
|
||||
menus,
|
||||
parentKey,
|
||||
selectKey,
|
||||
collapsed,
|
||||
setParentKey,
|
||||
setCollapsed,
|
||||
setSelectKey,
|
||||
}}>
|
||||
{children}
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLayoutContext = () => {
|
||||
const ctx = useContext(LayoutContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useLayoutContext must be used within LayoutProvider');
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {IMenus} from "@/domain/iSysRule.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Menu, type MenuProps} from "antd";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {useLayoutContext} from "@/layout/LayoutContext";
|
||||
import useMobile from "@/hooks/useMobile.ts";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import {findMenuByKey} from "@/layout/utils.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
/**
|
||||
* 构建菜单Item
|
||||
* @param nodes
|
||||
* @param t
|
||||
*/
|
||||
const transformMenus = (nodes: IMenus[], t: any): MenuItem[] => {
|
||||
return nodes.reduce<MenuItem[]>((acc, node) => {
|
||||
if (!['route', 'menu'].includes(node.type!) || !node.hidden) {
|
||||
return acc;
|
||||
}
|
||||
const menuItem: MenuItem = {
|
||||
label: node.local ? t(node.local) : node.name,
|
||||
icon: node.icon ? <IconFont name={node.icon} /> : undefined,
|
||||
key: node.key!,
|
||||
};
|
||||
if (node.type === 'menu' && node.children?.length) {
|
||||
(menuItem as any).children = transformMenus(node.children, t);
|
||||
}
|
||||
acc.push(menuItem);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const MenuRender: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const navigate = useNavigate();
|
||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const { menus, parentKey, selectKey, setSelectKey, setParentKey } = useLayoutContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setMenuItems(transformMenus(menus, t));
|
||||
return;
|
||||
}
|
||||
if (['columns', 'mix'].includes(layout) && parentKey) {
|
||||
const rule = menus.find(item => item.key === parentKey);
|
||||
setMenuItems(transformMenus(rule?.children || [], t));
|
||||
} else {
|
||||
setMenuItems(transformMenus(menus, t));
|
||||
}
|
||||
}, [menus, layout, parentKey, t, isMobile]);
|
||||
|
||||
const onMenuChange = (keyPath: string[]) => {
|
||||
setSelectKey(keyPath);
|
||||
if (['top', 'side'].includes(layout)) {
|
||||
setParentKey(keyPath[keyPath.length -1 ]);
|
||||
}
|
||||
const menu = findMenuByKey(menus, keyPath[0]);
|
||||
if (!menu || !menu.key) return;
|
||||
if(menu.type === 'route' && menu.link) {
|
||||
window.open(menu.path, '_blank');
|
||||
return;
|
||||
}
|
||||
if(menu.type === 'route' && menu.path) {
|
||||
navigate(menu.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ menuItems.length > 0 && (
|
||||
<Menu
|
||||
className={"border-b-0 flex-1"}
|
||||
mode={ layout === 'top' && !isMobile ? 'horizontal' : 'inline' }
|
||||
items={menuItems}
|
||||
onSelect={info => onMenuChange(info.keyPath)}
|
||||
selectedKeys={selectKey}
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MenuRender;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, {useState} from 'react';
|
||||
import { Button, Drawer, Space } from 'antd';
|
||||
import useGlobalStore from '@/stores/global';
|
||||
import MenuRender from '@/layout/MenuRender';
|
||||
import {GithubOutlined, HomeOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SettingOutlined} from '@ant-design/icons';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
|
||||
/**
|
||||
* 移动端抽屉菜单组件
|
||||
*/
|
||||
const MobileDrawerMenu: React.FC = () => {
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const themeDrawer = useGlobalStore(state => state.themeDrawer);
|
||||
const setThemeDrawer = useGlobalStore(state => state.setThemeDrawer);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="fixed bottom-8 left-8 z-999">
|
||||
<Button
|
||||
type={'primary'}
|
||||
shape="circle"
|
||||
size='large'
|
||||
icon={collapsed ? <MenuFoldOutlined/> : <MenuUnfoldOutlined/>}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
</div>
|
||||
<Drawer
|
||||
placement="left"
|
||||
closable={true}
|
||||
onClose={() => setCollapsed(false)}
|
||||
open={collapsed}
|
||||
styles={{
|
||||
section: {width: 280},
|
||||
header: {
|
||||
borderBottom: '1px solid ' + themeConfig.colorBorder,
|
||||
background: themeConfig.siderBg,
|
||||
color: themeConfig.siderColor,
|
||||
},
|
||||
body: {
|
||||
padding: 0,
|
||||
background: themeConfig.siderBg,
|
||||
},
|
||||
}}
|
||||
footer={(
|
||||
<Space>
|
||||
<Button icon={<HomeOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://xin-admin.com')}/>
|
||||
<Button icon={<GithubOutlined/>} size={'large'} type={'text'}
|
||||
onClick={() => window.open('https://github.com/xin-admin/xin-admin-ui')}/>
|
||||
<LanguageSwitcher size={"large"} type={'text'} />
|
||||
<Button onClick={() => setThemeDrawer(!themeDrawer)} icon={<SettingOutlined/>} size={"large"} type={'text'}/>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
background: themeConfig.siderBg,
|
||||
}}
|
||||
>
|
||||
<MenuRender />
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileDrawerMenu;
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import {debounce} from 'lodash';
|
||||
import {Button, Col, ColorPicker, Divider, Drawer, InputNumber, Row, Select, Switch, theme, Tooltip} from 'antd';
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {configTheme, darkColorTheme, defaultColorTheme} from "@/layout/theme.ts";
|
||||
import {algorithmOptions} from "@/layout/algorithm.ts";
|
||||
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const {useToken} = theme;
|
||||
|
||||
|
||||
// 主题配置项
|
||||
const THEME_CONFIGS = [
|
||||
{key: 'colorPrimary', label: 'layout.colorPrimary'},
|
||||
{key: 'colorText', label: 'layout.colorText'},
|
||||
{key: 'colorBg', label: 'layout.colorBg'},
|
||||
{key: 'colorSuccess', label: 'layout.colorSuccess'},
|
||||
{key: 'colorWarning', label: 'layout.colorWarning'},
|
||||
{key: 'colorError', label: 'layout.colorError'},
|
||||
{key: 'bodyBg', label: 'layout.bodyBg'},
|
||||
{key: 'footerBg', label: 'layout.footerBg'},
|
||||
{key: 'headerBg', label: 'layout.headerBg'},
|
||||
{key: 'headerColor', label: 'layout.headerColor'},
|
||||
{key: 'siderBg', label: 'layout.siderBg'},
|
||||
{key: 'siderColor', label: 'layout.siderColor'},
|
||||
{key: 'colorBorder', label: 'layout.colorBorder'},
|
||||
];
|
||||
|
||||
// 风格配置项
|
||||
const STYLE_CONFIGS = [
|
||||
{key: 'fixedFooter', label: 'layout.fixedFooter', type: 'switch'},
|
||||
{key: 'borderRadius', label: 'layout.borderRadius', type: 'number', min: 0, max: 30},
|
||||
{key: 'controlHeight', label: 'layout.controlHeight', type: 'number', min: 0},
|
||||
{key: 'headerPadding', label: 'layout.headerPadding', type: 'number', min: 0},
|
||||
{key: 'headerHeight', label: 'layout.headerHeight', type: 'number', min: 0},
|
||||
{key: 'siderWeight', label: 'layout.siderWeight', type: 'number', min: 0},
|
||||
{key: 'bodyPadding', label: 'layout.bodyPadding', type: 'number', min: 0},
|
||||
];
|
||||
|
||||
// 预设主题列表
|
||||
const THEME_LIST = [
|
||||
{background: '/static/theme/default.svg', name: 'light', title: 'layout.themeLight'},
|
||||
{background: '/static/theme/dark.svg', name: 'dark', title: 'layout.themeDark'},
|
||||
];
|
||||
|
||||
// 布局配置
|
||||
const LAYOUT_CONFIGS = [
|
||||
{
|
||||
key: 'side', title: 'layout.layoutSide', render: (token: any) => (
|
||||
<>
|
||||
<div className="rounded-sm w-full h-6 mb-1.5" style={{background: token.colorPrimaryBorder}}/>
|
||||
<div className="flex">
|
||||
<div className="rounded-sm h-16 w-6" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm flex-1 ml-1.5" style={{background: token.colorPrimaryBg}}/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'top', title: 'layout.layoutTop', render: (token: any) => (
|
||||
<>
|
||||
<div className="rounded-sm h-6 w-full mb-1.5" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm h-16" style={{background: token.colorPrimaryBg}}/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'mix', title: 'layout.layoutMix', render: (token: any) => (
|
||||
<>
|
||||
<div className="rounded-sm w-full h-6 mb-1.5" style={{background: token.colorPrimary}}/>
|
||||
<div className="flex">
|
||||
<div className="rounded-sm h-16 w-6" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm flex-1 ml-1.5" style={{background: token.colorPrimaryBg}}/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'columns', title: 'layout.layoutColumns', render: (token: any) => (
|
||||
<div className="flex">
|
||||
<div className="rounded-sm mr-1.5 w-3 h-24" style={{background: token.colorPrimary}}/>
|
||||
<div className="rounded-sm mr-1.5 w-6 h-24" style={{background: token.colorPrimaryHover}}/>
|
||||
<div className="rounded-sm flex-auto h-24" style={{background: token.colorPrimaryBg}}/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
const SettingDrawer: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const {token} = useToken();
|
||||
const themeDrawer = useGlobalStore(state => state.themeDrawer);
|
||||
const setThemeDrawer = useGlobalStore(state => state.setThemeDrawer);
|
||||
const setThemeConfig = useGlobalStore(state => state.setThemeConfig);
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const setLayout = useGlobalStore(state => state.setLayout);
|
||||
|
||||
// 翻译后的算法选项
|
||||
const translatedAlgorithmOptions = algorithmOptions?.map(option => ({
|
||||
...option,
|
||||
label: t(option.label as string)
|
||||
})) || [];
|
||||
|
||||
// 防抖更新主题配置(带过渡动画)
|
||||
const changeSetting = debounce((key: string, value: any) => {
|
||||
setThemeConfig({...themeConfig, [key]: value});
|
||||
}, 400, {leading: true, trailing: false});
|
||||
|
||||
// 处理主题切换(带圆形扩散动画)
|
||||
const handleThemeChange = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-theme]');
|
||||
if (!target) return;
|
||||
|
||||
const themeName = (target as HTMLElement).dataset.theme;
|
||||
const themeMap = {
|
||||
dark: darkColorTheme,
|
||||
light: defaultColorTheme
|
||||
};
|
||||
|
||||
if (themeMap[themeName as keyof typeof themeMap]) {
|
||||
setThemeConfig({...themeConfig, ...themeMap[themeName as keyof typeof themeMap]});
|
||||
}
|
||||
};
|
||||
|
||||
// 重置主题(带过渡动画)
|
||||
const resetTheme = () => {
|
||||
setThemeConfig({...configTheme, ...defaultColorTheme});
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
styles={{body: {paddingTop: 10}}}
|
||||
placement="right"
|
||||
closable={false}
|
||||
onClose={() => setThemeDrawer(false)}
|
||||
open={themeDrawer}
|
||||
footer={(
|
||||
<div className="flex w-full justify-between">
|
||||
<Button onClick={resetTheme}>
|
||||
{t('layout.resetTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{/* 布局样式 */}
|
||||
<Divider>{t('layout.layoutStyle')}</Divider>
|
||||
<Row gutter={[20, 20]}>
|
||||
{LAYOUT_CONFIGS.map(({key, title, render}) => (
|
||||
<Col span={12} key={key}>
|
||||
<Tooltip title={t(title)}>
|
||||
<div
|
||||
className="p-2 rounded-lg cursor-pointer"
|
||||
style={{
|
||||
boxShadow: token.boxShadow,
|
||||
borderRadius: token.borderRadius,
|
||||
border: layout === key ? `2px solid ${token.colorPrimary}` : '2px solid transparent',
|
||||
}}
|
||||
onClick={() => setLayout(key as any)}
|
||||
>
|
||||
{render(token)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 预设主题 */}
|
||||
<Divider>{t('layout.presetTheme')}</Divider>
|
||||
<Row gutter={20} onClick={handleThemeChange}>
|
||||
{THEME_LIST.map((item) => (
|
||||
<Col span={8} key={item.name} className="mb-2.5">
|
||||
<div
|
||||
data-theme={item.name}
|
||||
className="cursor-pointer overflow-hidden border-solid"
|
||||
style={{
|
||||
borderRadius: token.borderRadius,
|
||||
borderWidth: themeConfig.themeScheme === item.name ? '2px' : '0px',
|
||||
borderColor: themeConfig.themeScheme === item.name ? token.colorPrimary : 'transparent'
|
||||
}}
|
||||
>
|
||||
<img src={item.background} alt={item.name}/>
|
||||
</div>
|
||||
<div className="text-center mt-1.5">{t(item.title)}</div>
|
||||
</Col>
|
||||
))}
|
||||
<Col span={24} className="mb-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>{t('layout.themeAlgorithm')}</div>
|
||||
<Select
|
||||
value={themeConfig.algorithm}
|
||||
style={{ width: 160 }}
|
||||
onChange={(value) => changeSetting('algorithm', value)}
|
||||
options={translatedAlgorithmOptions}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 主题颜色 */}
|
||||
<Divider>{t('layout.themeColor')}</Divider>
|
||||
{THEME_CONFIGS.map(({key, label}) => (
|
||||
<div key={key} className="flex justify-between items-center mb-2.5">
|
||||
<div>{t(label)}</div>
|
||||
<ColorPicker
|
||||
showText
|
||||
value={themeConfig[key as keyof typeof themeConfig] as string}
|
||||
onChange={(value) => changeSetting(key, value.toCssString())}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 风格配置 */}
|
||||
<Divider>{t('layout.styleConfig')}</Divider>
|
||||
{STYLE_CONFIGS.map(({key, label, type, ...rest}) => (
|
||||
<div key={key} className="flex justify-between items-center mb-2.5">
|
||||
<div>{t(label)}</div>
|
||||
{type === 'switch' ? (
|
||||
<Switch
|
||||
value={themeConfig[key as keyof typeof themeConfig] as boolean}
|
||||
onChange={(value) => changeSetting(key, value)}
|
||||
/>
|
||||
) : (
|
||||
<InputNumber
|
||||
value={themeConfig[key as keyof typeof themeConfig] as number}
|
||||
onChange={(value) => changeSetting(key, value)}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingDrawer;
|
||||
@@ -0,0 +1,37 @@
|
||||
import {type SelectProps, theme as antTheme} from "antd";
|
||||
|
||||
/** AntDesign 算法,你可以在此自定义你的算法 */
|
||||
const algorithm = {
|
||||
/** 默认算法 */
|
||||
defaultAlgorithm: antTheme.defaultAlgorithm,
|
||||
/** 暗黑模式算法 */
|
||||
darkAlgorithm: antTheme.darkAlgorithm,
|
||||
/** 默认 + 紧凑算法 */
|
||||
defaultCompactAlgorithm: [antTheme.defaultAlgorithm, antTheme.compactAlgorithm],
|
||||
/** 暗黑 + 紧凑算法 */
|
||||
darkCompactAlgorithm: [antTheme.darkAlgorithm, antTheme.compactAlgorithm],
|
||||
};
|
||||
|
||||
/** 算法切换选择器的 Options (使用 i18n key) */
|
||||
export const algorithmOptions: SelectProps['options'] = [
|
||||
{
|
||||
label: 'layout.algorithmDefault',
|
||||
value: 'defaultAlgorithm',
|
||||
},
|
||||
{
|
||||
label: 'layout.algorithmDark',
|
||||
value: 'darkAlgorithm',
|
||||
},
|
||||
{
|
||||
label: 'layout.algorithmDefaultCompact',
|
||||
value: 'defaultCompactAlgorithm',
|
||||
},
|
||||
{
|
||||
label: 'layout.algorithmDarkCompact',
|
||||
value: 'darkCompactAlgorithm',
|
||||
},
|
||||
]
|
||||
|
||||
export type algorithmType = keyof typeof algorithm;
|
||||
|
||||
export default algorithm;
|
||||
@@ -0,0 +1,84 @@
|
||||
import useGlobalStore from "@/stores/global";
|
||||
import {Layout} from "antd";
|
||||
import HeaderRender from "@/layout/HeaderRender";
|
||||
import FooterRender from "@/layout/FooterRender";
|
||||
import MenuRender from "@/layout/MenuRender";
|
||||
import MobileDrawerMenu from "@/layout/MobileDrawerMenu";
|
||||
import SettingDrawer from "@/layout/SettingDrawer";
|
||||
import PageTitle from "@/components/PageTitle";
|
||||
import {LayoutProvider, useLayoutContext} from "@/layout/LayoutContext";
|
||||
import {Outlet} from "react-router";
|
||||
import useMobile from "@/hooks/useMobile.ts";
|
||||
import ColumnsMenu from "@/layout/ColumnsMenu.tsx";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
const {Content, Sider} = Layout;
|
||||
|
||||
const LayoutContent: React.FC = () => {
|
||||
const isMobile = useMobile();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const layout = useGlobalStore(state => state.layout);
|
||||
const [siderWidth, setSiderWidth] = useState<number>(226);
|
||||
const {menus, collapsed, parentKey} = useLayoutContext();
|
||||
|
||||
useEffect(() => {
|
||||
const menuWidth = themeConfig.siderWeight ? themeConfig.siderWeight : 226;
|
||||
const columnWidth = layout === 'columns' ? 79 : 0;
|
||||
const rule = menus.find(item => item.key === parentKey);
|
||||
if(['mix', 'columns'].includes(layout)) {
|
||||
if(rule?.children && rule?.children.length) {
|
||||
setSiderWidth(columnWidth + menuWidth)
|
||||
} else {
|
||||
setSiderWidth(columnWidth);
|
||||
}
|
||||
} else {
|
||||
setSiderWidth(menuWidth);
|
||||
}
|
||||
}, [themeConfig, layout, menus, parentKey]);
|
||||
|
||||
return (
|
||||
<Layout className="min-h-screen" style={{ background: themeConfig.background }}>
|
||||
{/* 页面标题 */}
|
||||
<PageTitle />
|
||||
{/* 主题设置抽屉 */}
|
||||
<SettingDrawer />
|
||||
{/* 顶栏 */}
|
||||
<HeaderRender />
|
||||
{/* 移动端抽屉菜单 */}
|
||||
{ isMobile && <MobileDrawerMenu /> }
|
||||
{/* 内容区域 */}
|
||||
<Layout hasSider className={"relative"}>
|
||||
{ layout !== "top" && !isMobile && (
|
||||
<Sider
|
||||
collapsed={collapsed}
|
||||
width={siderWidth}
|
||||
style={{
|
||||
borderRight: "1px solid " + themeConfig.colorBorder,
|
||||
height: `calc(100vh - ${themeConfig.headerHeight}px)`,
|
||||
top: themeConfig.headerHeight,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
position: 'sticky'
|
||||
}}
|
||||
>
|
||||
{ layout === "columns" ? <ColumnsMenu/> : <MenuRender />}
|
||||
</Sider>
|
||||
)}
|
||||
<Layout>
|
||||
<Content style={{padding: themeConfig.bodyPadding}}>
|
||||
<Outlet/>
|
||||
</Content>
|
||||
<FooterRender/>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutRender = () => (
|
||||
<LayoutProvider>
|
||||
<LayoutContent />
|
||||
</LayoutProvider>
|
||||
);
|
||||
|
||||
export default LayoutRender;
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {ThemeProps} from "@/layout/typing.ts";
|
||||
|
||||
// 默认主题
|
||||
export const defaultColorTheme: ThemeProps = {
|
||||
themeScheme: "light",
|
||||
background: "transparent",
|
||||
colorPrimary: "#1677ff",
|
||||
borderRadius: 8,
|
||||
colorText: "#000",
|
||||
colorBg: "#fff",
|
||||
bodyBg: "#f9f9f9",
|
||||
footerBg: "#fff",
|
||||
headerBg: "#fff",
|
||||
headerColor: "#000",
|
||||
siderBg: "#fff",
|
||||
siderColor: "#000",
|
||||
colorBorder: "#f0f0f0",
|
||||
algorithm: "defaultAlgorithm",
|
||||
}
|
||||
|
||||
// 暗黑模式主题
|
||||
export const darkColorTheme: ThemeProps = {
|
||||
themeScheme: "dark",
|
||||
background: "transparent",
|
||||
borderRadius: 8,
|
||||
colorPrimary: "#1677ff",
|
||||
colorText: "#fff",
|
||||
colorBg: "#000",
|
||||
bodyBg: "#000",
|
||||
footerBg: "#141414",
|
||||
headerBg: "#141414",
|
||||
headerColor: "#fff",
|
||||
siderBg: "#141414",
|
||||
siderColor: "#fff",
|
||||
colorBorder: "#282828",
|
||||
algorithm: "darkAlgorithm",
|
||||
}
|
||||
|
||||
export const configTheme: ThemeProps = {
|
||||
// 主题
|
||||
themeScheme: "light",
|
||||
// 品牌色
|
||||
colorPrimary: "#1677ff",
|
||||
// 错误色
|
||||
colorError: "#ff4d4f",
|
||||
// 成功色
|
||||
colorSuccess: "#52c41a",
|
||||
// 警告色
|
||||
colorWarning: "#faad14",
|
||||
// 基础组件的圆角大小
|
||||
borderRadius: 10,
|
||||
// 按钮和输入框等基础控件的高度
|
||||
controlHeight: 32,
|
||||
// 头部两侧内边距
|
||||
headerPadding: 20,
|
||||
// 头部高度
|
||||
headerHeight: 56,
|
||||
// 侧边栏宽度
|
||||
siderWeight: 226,
|
||||
// 内容区域内边距
|
||||
bodyPadding: 20,
|
||||
// 固定页脚
|
||||
fixedFooter: false,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { algorithmType } from "@/layout/algorithm";
|
||||
|
||||
export type ThemeScheme = "light" | "dark" | "pink" | "green";
|
||||
|
||||
export type LayoutType = "side" | "top" | "mix" | "columns";
|
||||
|
||||
export interface ThemeProps {
|
||||
// 主题
|
||||
themeScheme?: ThemeScheme;
|
||||
// 背景
|
||||
background?: string;
|
||||
// 品牌色
|
||||
colorPrimary?: string;
|
||||
// 错误色
|
||||
colorError?: string;
|
||||
// 成功色
|
||||
colorSuccess?: string;
|
||||
// 警告色
|
||||
colorWarning?: string;
|
||||
// 基础组件的圆角大小
|
||||
borderRadius?: number;
|
||||
// 按钮和输入框等基础控件的高度
|
||||
controlHeight?: number;
|
||||
// 头部两侧内边距
|
||||
headerPadding?: number;
|
||||
// 头部高度
|
||||
headerHeight?: number;
|
||||
// 侧边栏宽度
|
||||
siderWeight?: number;
|
||||
// 内容区域内边距
|
||||
bodyPadding?: number;
|
||||
// 固定页脚
|
||||
fixedFooter?: boolean;
|
||||
// 基础文字颜色
|
||||
colorText?: string;
|
||||
// 基础背景颜色
|
||||
colorBg?: string;
|
||||
// 内容区域背景色
|
||||
bodyBg?: string;
|
||||
// 页脚背景色
|
||||
footerBg?: string;
|
||||
// 头部背景色
|
||||
headerBg?: string;
|
||||
// 头部文字颜色
|
||||
headerColor?: string;
|
||||
// 侧边栏背景色
|
||||
siderBg?: string;
|
||||
// 侧边栏文字颜色
|
||||
siderColor?: string;
|
||||
// 布局分割线边框颜色
|
||||
colorBorder?: string;
|
||||
// 算法
|
||||
algorithm?: algorithmType;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {IMenus} from "../domain/iSysRule";
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
href?: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
local?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建面包屑Map
|
||||
* @param menus
|
||||
*/
|
||||
export function buildBreadcrumbMap(menus: IMenus[]) {
|
||||
const breadcrumbMap: Record<string, BreadcrumbItem[]> = {};
|
||||
const processMenu = (item: IMenus, breadcrumbs: BreadcrumbItem[] = []) => {
|
||||
if (!item.key) return;
|
||||
const currentBreadcrumb: BreadcrumbItem = {
|
||||
href: item.path,
|
||||
title: item.name,
|
||||
icon: item.icon,
|
||||
local: item.local,
|
||||
};
|
||||
const newBreadcrumbs = [...breadcrumbs, currentBreadcrumb];
|
||||
breadcrumbMap[item.key] = newBreadcrumbs;
|
||||
if (item.children && item.children.length) {
|
||||
item.children.forEach(child => processMenu(child, newBreadcrumbs));
|
||||
}
|
||||
};
|
||||
menus.forEach(item => processMenu(item));
|
||||
return breadcrumbMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径查找菜单项
|
||||
* @param menus
|
||||
* @param path
|
||||
*/
|
||||
export function findMenuByPath(menus: IMenus[], path: string): IMenus | null {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === path) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const result = findMenuByPath(menu.children, path);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径构建父菜单 keys
|
||||
* @param menus
|
||||
* @param path
|
||||
* @param parentKeys
|
||||
*/
|
||||
export function getMenuParentKeys(menus: IMenus[], path: string, parentKeys: string[] = []): string[] | null {
|
||||
for (const menu of menus) {
|
||||
if (!menu.key) continue;
|
||||
const keys = [...parentKeys, menu.key];
|
||||
if (menu.path === path) {
|
||||
return keys;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const result = getMenuParentKeys(menu.children, path, keys);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径查找菜单项
|
||||
* @param menus
|
||||
* @param key
|
||||
*/
|
||||
export function findMenuByKey(menus: IMenus[], key: string): IMenus | null {
|
||||
for (const menu of menus) {
|
||||
if (menu.key === key) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const result = findMenuByKey(menu.children, key);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user