first commit

This commit is contained in:
liu
2026-07-13 15:23:29 +08:00
commit 50885a98c8
473 changed files with 33772 additions and 0 deletions
+55
View File
@@ -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;
+72
View File
@@ -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;
+34
View File
@@ -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;
+116
View File
@@ -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;
+131
View File
@@ -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
+66
View File
@@ -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;
};
+90
View File
@@ -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;
+71
View File
@@ -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;
+237
View File
@@ -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;
+37
View File
@@ -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;
+84
View File
@@ -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;
+64
View File
@@ -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,
}
+54
View File
@@ -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;
}
+95
View File
@@ -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;
}