前端重构
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* KRA - Basic Layout
|
||||
* Main application layout with sidebar and header
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet, useLocation, useModel, history } from '@umijs/max';
|
||||
import { ProLayout } from '@ant-design/pro-components';
|
||||
import type { ProLayoutProps } from '@ant-design/pro-components';
|
||||
import { Watermark } from 'antd';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { BottomInfo } from '@/components/BottomInfo';
|
||||
import HeaderTools from './components/HeaderTools';
|
||||
import Tabs from './components/Tabs';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import defaultSettings from '../../config/defaultSettings';
|
||||
|
||||
const BasicLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { isMobile } = useResponsive();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [pathname, setPathname] = useState(location.pathname);
|
||||
|
||||
const currentUser = initialState?.currentUser;
|
||||
const settings = initialState?.settings || defaultSettings;
|
||||
|
||||
useEffect(() => {
|
||||
setPathname(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const layoutProps: ProLayoutProps = {
|
||||
title: 'Kratos Admin',
|
||||
logo: '/logo.svg',
|
||||
layout: settings.layout || 'mix',
|
||||
navTheme: settings.navTheme || 'light',
|
||||
contentWidth: settings.contentWidth || 'Fluid',
|
||||
fixedHeader: settings.fixedHeader ?? true,
|
||||
fixSiderbar: settings.fixSiderbar ?? true,
|
||||
colorPrimary: settings.colorPrimary || '#1890ff',
|
||||
splitMenus: settings.splitMenus ?? false,
|
||||
collapsed,
|
||||
onCollapse: setCollapsed,
|
||||
location: { pathname },
|
||||
menu: {
|
||||
locale: false,
|
||||
request: async () => {
|
||||
// Return menu from initialState
|
||||
return initialState?.menus || [];
|
||||
},
|
||||
},
|
||||
menuItemRender: (item, dom) => (
|
||||
<div onClick={() => {
|
||||
setPathname(item.path || '/');
|
||||
history.push(item.path || '/');
|
||||
}}>
|
||||
{dom}
|
||||
</div>
|
||||
),
|
||||
headerTitleRender: (logo, title) => (
|
||||
<Logo collapsed={collapsed} showTitle={!collapsed} />
|
||||
),
|
||||
actionsRender: () => [
|
||||
<HeaderTools key="tools" />,
|
||||
],
|
||||
footerRender: () => <BottomInfo />,
|
||||
menuFooterRender: (props) => {
|
||||
if (props?.collapsed) return undefined;
|
||||
return (
|
||||
<div style={{ textAlign: 'center', paddingBlockStart: 12 }}>
|
||||
<div>© {new Date().getFullYear()} KRA</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Watermark
|
||||
content={settings.showWatermark ? currentUser?.nickName : undefined}
|
||||
font={{ color: 'rgba(0, 0, 0, 0.15)' }}
|
||||
gap={[180, 150]}
|
||||
zIndex={9999}
|
||||
>
|
||||
<ProLayout {...layoutProps}>
|
||||
{settings.showTabs && <Tabs />}
|
||||
<Outlet />
|
||||
</ProLayout>
|
||||
</Watermark>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicLayout;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* KRA - Blank Layout
|
||||
* Minimal layout for special pages
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Outlet } from '@umijs/max';
|
||||
|
||||
const BlankLayout: React.FC = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default BlankLayout;
|
||||
@@ -0,0 +1,37 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 24px 50px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* KRA - User Layout
|
||||
* Layout for login/register pages
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Outlet } from '@umijs/max';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { BottomInfo } from '@/components/BottomInfo';
|
||||
import styles from './UserLayout.less';
|
||||
|
||||
const UserLayout: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<Logo size="large" showTitle />
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<BottomInfo />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserLayout;
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* KRA - Header Tools Component
|
||||
* Header toolbar with user info, settings, notifications
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Space, Dropdown, Avatar, Badge, Tooltip, Switch } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
BellOutlined,
|
||||
FullscreenOutlined,
|
||||
FullscreenExitOutlined,
|
||||
SunOutlined,
|
||||
MoonOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useModel, history } from '@umijs/max';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import Screenfull from './Screenfull';
|
||||
|
||||
const HeaderTools: React.FC = () => {
|
||||
const { initialState, setInitialState } = useModel('@@initialState');
|
||||
const { logout } = useAuth();
|
||||
|
||||
const currentUser = initialState?.currentUser;
|
||||
const isDark = initialState?.settings?.navTheme === 'realDark';
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'profile':
|
||||
history.push('/person');
|
||||
break;
|
||||
case 'settings':
|
||||
history.push('/system/config');
|
||||
break;
|
||||
case 'logout':
|
||||
logout();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
},
|
||||
];
|
||||
|
||||
const toggleTheme = () => {
|
||||
setInitialState((s: any) => ({
|
||||
...s,
|
||||
settings: {
|
||||
...s?.settings,
|
||||
navTheme: isDark ? 'light' : 'realDark',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Space size={16}>
|
||||
<Tooltip title={isDark ? '切换到亮色模式' : '切换到暗色模式'}>
|
||||
<Switch
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
checked={isDark}
|
||||
onChange={toggleTheme}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Screenfull />
|
||||
|
||||
<Badge count={0} size="small">
|
||||
<Tooltip title="通知">
|
||||
<BellOutlined style={{ fontSize: 16, cursor: 'pointer' }} />
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar
|
||||
size="small"
|
||||
src={currentUser?.headerImg}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<span>{currentUser?.nickName || currentUser?.userName || '用户'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderTools;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* KRA - Screenfull Component
|
||||
* Fullscreen toggle button
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
|
||||
|
||||
const Screenfull: React.FC = () => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const checkFullscreen = useCallback(() => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('fullscreenchange', checkFullscreen);
|
||||
return () => document.removeEventListener('fullscreenchange', checkFullscreen);
|
||||
}, [checkFullscreen]);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fullscreen error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={isFullscreen ? '退出全屏' : '全屏'}>
|
||||
{isFullscreen ? (
|
||||
<FullscreenExitOutlined
|
||||
style={{ fontSize: 16, cursor: 'pointer' }}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
) : (
|
||||
<FullscreenOutlined
|
||||
style={{ fontSize: 16, cursor: 'pointer' }}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default Screenfull;
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* KRA - Global Search Component
|
||||
* Command palette style search for quick navigation
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Modal, Input, List, Empty, Tag } from 'antd';
|
||||
import { SearchOutlined, FileOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { history } from '@umijs/max';
|
||||
import { useAsyncRouters } from '@/models';
|
||||
import * as Icons from '@ant-design/icons';
|
||||
|
||||
interface SearchItem {
|
||||
key: string;
|
||||
title: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
type: 'menu' | 'action';
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
interface SearchProps {
|
||||
/** Whether the search modal is visible */
|
||||
visible: boolean;
|
||||
/** Callback when modal closes */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Get icon component by name
|
||||
const getIcon = (iconName?: string): React.ReactNode => {
|
||||
if (!iconName) return <FileOutlined />;
|
||||
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
|
||||
return IconComponent ? <IconComponent /> : <FileOutlined />;
|
||||
};
|
||||
|
||||
const Search: React.FC<SearchProps> = ({ visible, onClose }) => {
|
||||
const asyncRouters = useAsyncRouters();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<any>(null);
|
||||
|
||||
// Flatten menu items for search
|
||||
const searchItems = useMemo(() => {
|
||||
const items: SearchItem[] = [];
|
||||
|
||||
const flattenMenus = (menus: any[], parent?: string) => {
|
||||
menus?.forEach((menu) => {
|
||||
if (menu.hidden) return;
|
||||
|
||||
items.push({
|
||||
key: menu.name,
|
||||
title: menu.meta?.title || menu.name,
|
||||
path: menu.path?.startsWith('/') ? menu.path : `/${menu.path}`,
|
||||
icon: menu.meta?.icon,
|
||||
type: 'menu',
|
||||
parent,
|
||||
});
|
||||
|
||||
if (menu.children?.length > 0) {
|
||||
flattenMenus(menu.children, menu.meta?.title || menu.name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (rootMenu?.children) {
|
||||
flattenMenus(rootMenu.children);
|
||||
}
|
||||
|
||||
// Add some common actions
|
||||
items.push(
|
||||
{ key: 'action-profile', title: '个人中心', path: '/person', icon: 'UserOutlined', type: 'action' },
|
||||
{ key: 'action-settings', title: '系统设置', path: '/system/config', icon: 'SettingOutlined', type: 'action' },
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [asyncRouters]);
|
||||
|
||||
// Filter items based on keyword
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!keyword.trim()) {
|
||||
return searchItems.slice(0, 10);
|
||||
}
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return searchItems.filter((item) => {
|
||||
return (
|
||||
item.title.toLowerCase().includes(lowerKeyword) ||
|
||||
item.path.toLowerCase().includes(lowerKeyword) ||
|
||||
item.parent?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}).slice(0, 20);
|
||||
}, [searchItems, keyword]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setKeyword('');
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (filteredItems[selectedIndex]) {
|
||||
handleSelect(filteredItems[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
}, [filteredItems, selectedIndex, onClose]);
|
||||
|
||||
// Handle item selection
|
||||
const handleSelect = (item: SearchItem) => {
|
||||
history.push(item.path);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
closable={false}
|
||||
width={600}
|
||||
centered
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
}}
|
||||
style={{ top: 100 }}
|
||||
>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
prefix={<SearchOutlined style={{ color: '#999' }} />}
|
||||
placeholder="搜索菜单或功能..."
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
bordered={false}
|
||||
size="large"
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 400, overflow: 'auto' }}>
|
||||
{filteredItems.length > 0 ? (
|
||||
<List
|
||||
dataSource={filteredItems}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item
|
||||
onClick={() => handleSelect(item)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: index === selectedIndex ? '#f5f5f5' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={getIcon(item.icon)}
|
||||
title={
|
||||
<span>
|
||||
{item.title}
|
||||
{item.parent && (
|
||||
<Tag color="blue" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
{item.parent}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
{item.path}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description="未找到匹配的菜单或功能"
|
||||
style={{ padding: '40px 0' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
color: '#999',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 4 }}>↑</kbd>
|
||||
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 8 }}>↓</kbd>
|
||||
导航
|
||||
</span>
|
||||
<span>
|
||||
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 4 }}>Enter</kbd>
|
||||
选择
|
||||
</span>
|
||||
<span>
|
||||
<kbd style={{ padding: '2px 6px', backgroundColor: '#f5f5f5', borderRadius: 4, marginRight: 4 }}>Esc</kbd>
|
||||
关闭
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* KRA - Settings Drawer Styles
|
||||
*/
|
||||
|
||||
.kra-setting-drawer {
|
||||
.ant-drawer-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-setting-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kra-setting-tabs {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
padding: 8px 24px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
padding: 16px 24px;
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-setting-content {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.kra-setting-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-setting-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kra-setting-divider {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.kra-setting-section-title {
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.kra-setting-card {
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// Setting Item
|
||||
.kra-setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-setting-item-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kra-setting-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.kra-setting-description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// Theme Mode Selector
|
||||
.kra-theme-mode-selector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.kra-theme-mode-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme Color Picker
|
||||
.kra-theme-color-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.kra-color-item {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: 0 0 0 2px #fff, 0 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Layout Mode Selector
|
||||
.kra-layout-mode-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kra-layout-mode-item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-layout-preview {
|
||||
height: 60px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.preview-side {
|
||||
width: 20px;
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.preview-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
height: 12px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-top-header {
|
||||
height: 12px;
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-layout-mode-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.kra-layout-mode-check {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
// Size Config
|
||||
.kra-size-config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-size-config-info {
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-size-config-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.unit {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// System Info Grid
|
||||
.kra-system-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.kra-system-info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:nth-last-child(-n+2) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
// Config Action Items
|
||||
.kra-config-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-config-action-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.danger {
|
||||
background: #fff1f0;
|
||||
border: 1px solid #ffccc7;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-config-action-info {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// About Project
|
||||
.kra-about-project {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.kra-about-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.kra-about-info {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-about-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
:global(.dark) {
|
||||
.kra-setting-drawer {
|
||||
.ant-drawer-header {
|
||||
border-color: #303030;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-setting-tabs {
|
||||
.ant-tabs-nav {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-setting-section-title {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.kra-setting-divider {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.kra-setting-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.kra-setting-label {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.kra-setting-item {
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.kra-theme-mode-selector {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.kra-theme-mode-item {
|
||||
color: #999;
|
||||
|
||||
&:hover {
|
||||
background: #303030;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-layout-mode-item {
|
||||
background: #262626;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.kra-layout-preview {
|
||||
background: #1f1f1f;
|
||||
|
||||
.preview-header {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.preview-content,
|
||||
.preview-body {
|
||||
background: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-layout-mode-title {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.kra-size-config-item,
|
||||
.kra-config-action-item {
|
||||
background: #262626;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.kra-size-config-info,
|
||||
.kra-config-action-info {
|
||||
h4 {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-system-info-item {
|
||||
border-color: #303030;
|
||||
|
||||
.value {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
.kra-about-logo {
|
||||
background: #262626;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.kra-about-info {
|
||||
h4 {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* KRA - Settings Drawer Component
|
||||
* System configuration panel with appearance, layout, and general settings
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Drawer, Tabs, Switch, Select, InputNumber, Button, message, Modal, Upload } from 'antd';
|
||||
import {
|
||||
BgColorsOutlined,
|
||||
LayoutOutlined,
|
||||
SettingOutlined,
|
||||
CheckOutlined,
|
||||
SunOutlined,
|
||||
MoonOutlined,
|
||||
DesktopOutlined,
|
||||
ReloadOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
GithubOutlined,
|
||||
BookOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAppStore, type DarkMode, type SideMode, type TransitionType, type GlobalSize } from '@/models/app';
|
||||
import Logo from '@/components/Logo/Logo';
|
||||
import './SettingDrawer.less';
|
||||
|
||||
interface SettingDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Theme colors
|
||||
const themeColors = [
|
||||
{ name: '拂晓蓝', color: '#1890ff' },
|
||||
{ name: '薄暮红', color: '#f5222d' },
|
||||
{ name: '火山橙', color: '#fa541c' },
|
||||
{ name: '日暮黄', color: '#faad14' },
|
||||
{ name: '极光绿', color: '#52c41a' },
|
||||
{ name: '明青', color: '#13c2c2' },
|
||||
{ name: '酱紫', color: '#722ed1' },
|
||||
{ name: '洋红', color: '#eb2f96' },
|
||||
];
|
||||
|
||||
// Layout modes
|
||||
const layoutModes: { key: SideMode; title: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'normal', title: '侧边菜单', icon: <LayoutOutlined /> },
|
||||
{ key: 'head', title: '顶部菜单', icon: <DesktopOutlined /> },
|
||||
{ key: 'combination', title: '混合菜单', icon: <LayoutOutlined /> },
|
||||
];
|
||||
|
||||
// Section Header Component
|
||||
const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
<div className="kra-setting-section-header">
|
||||
<div className="kra-setting-divider" />
|
||||
<span className="kra-setting-section-title">{title}</span>
|
||||
<div className="kra-setting-divider" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Setting Item Component
|
||||
const SettingItem: React.FC<{
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ label, description, children }) => (
|
||||
<div className="kra-setting-item">
|
||||
<div className="kra-setting-item-label">
|
||||
<span className="kra-setting-label">{label}</span>
|
||||
{description && <span className="kra-setting-description">{description}</span>}
|
||||
</div>
|
||||
<div className="kra-setting-item-control">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Appearance Settings Tab
|
||||
const AppearanceSettings: React.FC = () => {
|
||||
const { config, setDarkMode, setPrimaryColor, setWeakness, setGrey, setShowWatermark, setGlobalSize } = useAppStore();
|
||||
|
||||
const darkModeOptions: { key: DarkMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: 'light', icon: <SunOutlined />, label: '浅色' },
|
||||
{ key: 'dark', icon: <MoonOutlined />, label: '深色' },
|
||||
{ key: 'auto', icon: <DesktopOutlined />, label: '跟随系统' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="kra-setting-content">
|
||||
{/* Theme Mode */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="主题模式" />
|
||||
<div className="kra-theme-mode-selector">
|
||||
{darkModeOptions.map((option) => (
|
||||
<div
|
||||
key={option.key}
|
||||
className={`kra-theme-mode-item ${config.darkMode === option.key ? 'active' : ''}`}
|
||||
onClick={() => setDarkMode(option.key)}
|
||||
style={config.darkMode === option.key ? { backgroundColor: config.primaryColor } : {}}
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Color */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="主题颜色" />
|
||||
<div className="kra-theme-color-picker">
|
||||
{themeColors.map((item) => (
|
||||
<div
|
||||
key={item.color}
|
||||
className={`kra-color-item ${config.primaryColor === item.color ? 'active' : ''}`}
|
||||
style={{ backgroundColor: item.color }}
|
||||
onClick={() => setPrimaryColor(item.color)}
|
||||
title={item.name}
|
||||
>
|
||||
{config.primaryColor === item.color && <CheckOutlined />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Size */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="全局尺寸" />
|
||||
<div className="kra-setting-card">
|
||||
<SettingItem label="全局尺寸" description="设置全局组件尺寸">
|
||||
<Select
|
||||
value={config.globalSize}
|
||||
onChange={(value: GlobalSize) => setGlobalSize(value)}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'default', label: '默认就好了' },
|
||||
{ value: 'large', label: '大点好' },
|
||||
{ value: 'small', label: '小的也不错' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Accessibility */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="视觉辅助" />
|
||||
<div className="kra-setting-card">
|
||||
<SettingItem label="灰色模式" description="降低色彩饱和度">
|
||||
<Switch checked={config.grey} onChange={setGrey} />
|
||||
</SettingItem>
|
||||
<SettingItem label="色弱模式" description="优化色彩对比度">
|
||||
<Switch checked={config.weakness} onChange={setWeakness} />
|
||||
</SettingItem>
|
||||
<SettingItem label="显示水印" description="在页面显示水印标识">
|
||||
<Switch checked={config.showWatermark} onChange={setShowWatermark} />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Layout Settings Tab
|
||||
const LayoutSettings: React.FC = () => {
|
||||
const {
|
||||
config,
|
||||
setSideMode,
|
||||
setShowTabs,
|
||||
setTransitionType,
|
||||
setLayoutSideWidth,
|
||||
setLayoutSideCollapsedWidth,
|
||||
} = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="kra-setting-content">
|
||||
{/* Layout Mode */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="布局模式" />
|
||||
<div className="kra-layout-mode-selector">
|
||||
{layoutModes.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className={`kra-layout-mode-item ${config.sideMode === mode.key ? 'active' : ''}`}
|
||||
onClick={() => setSideMode(mode.key)}
|
||||
style={config.sideMode === mode.key ? { borderColor: config.primaryColor } : {}}
|
||||
>
|
||||
<div className="kra-layout-preview">
|
||||
{mode.key === 'normal' && (
|
||||
<>
|
||||
<div className="preview-side" style={{ backgroundColor: config.primaryColor }} />
|
||||
<div className="preview-main">
|
||||
<div className="preview-header" />
|
||||
<div className="preview-content" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mode.key === 'head' && (
|
||||
<>
|
||||
<div className="preview-top-header" style={{ backgroundColor: config.primaryColor }} />
|
||||
<div className="preview-body" />
|
||||
</>
|
||||
)}
|
||||
{mode.key === 'combination' && (
|
||||
<>
|
||||
<div className="preview-top-header" style={{ backgroundColor: config.primaryColor }} />
|
||||
<div className="preview-main">
|
||||
<div className="preview-side" />
|
||||
<div className="preview-content" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="kra-layout-mode-title">{mode.title}</span>
|
||||
{config.sideMode === mode.key && (
|
||||
<div className="kra-layout-mode-check" style={{ backgroundColor: config.primaryColor }}>
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Config */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="界面配置" />
|
||||
<div className="kra-setting-card">
|
||||
<SettingItem label="显示标签页" description="页面标签导航">
|
||||
<Switch checked={config.showTabs} onChange={setShowTabs} />
|
||||
</SettingItem>
|
||||
<SettingItem label="页面切换动画" description="页面过渡效果">
|
||||
<Select
|
||||
value={config.transitionType}
|
||||
onChange={(value: TransitionType) => setTransitionType(value)}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'fade', label: '淡入淡出' },
|
||||
{ value: 'slide', label: '滑动' },
|
||||
{ value: 'zoom', label: '缩放' },
|
||||
{ value: 'none', label: '无动画' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Config */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="尺寸配置" />
|
||||
<div className="kra-setting-card">
|
||||
<div className="kra-size-config-item">
|
||||
<div className="kra-size-config-info">
|
||||
<h4>侧边栏展开宽度</h4>
|
||||
<p>侧边栏完全展开时的宽度</p>
|
||||
</div>
|
||||
<div className="kra-size-config-control">
|
||||
<InputNumber
|
||||
value={config.layoutSideWidth}
|
||||
min={150}
|
||||
max={400}
|
||||
step={10}
|
||||
onChange={(value) => value && setLayoutSideWidth(value)}
|
||||
/>
|
||||
<span className="unit">px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kra-size-config-item">
|
||||
<div className="kra-size-config-info">
|
||||
<h4>侧边栏收缩宽度</h4>
|
||||
<p>侧边栏收缩时的最小宽度</p>
|
||||
</div>
|
||||
<div className="kra-size-config-control">
|
||||
<InputNumber
|
||||
value={config.layoutSideCollapsedWidth}
|
||||
min={60}
|
||||
max={100}
|
||||
onChange={(value) => value && setLayoutSideCollapsedWidth(value)}
|
||||
/>
|
||||
<span className="unit">px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// General Settings Tab
|
||||
const GeneralSettings: React.FC = () => {
|
||||
const { config, resetConfig, updateConfig } = useAppStore();
|
||||
const [browserInfo, setBrowserInfo] = useState('');
|
||||
const [screenResolution, setScreenResolution] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent;
|
||||
if (userAgent.includes('Chrome')) {
|
||||
setBrowserInfo('Chrome');
|
||||
} else if (userAgent.includes('Firefox')) {
|
||||
setBrowserInfo('Firefox');
|
||||
} else if (userAgent.includes('Safari')) {
|
||||
setBrowserInfo('Safari');
|
||||
} else if (userAgent.includes('Edge')) {
|
||||
setBrowserInfo('Edge');
|
||||
} else {
|
||||
setBrowserInfo('Unknown');
|
||||
}
|
||||
setScreenResolution(`${screen.width}×${screen.height}`);
|
||||
}, []);
|
||||
|
||||
const handleResetConfig = () => {
|
||||
Modal.confirm({
|
||||
title: '重置配置',
|
||||
content: '确定要重置所有配置吗?此操作不可撤销。',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
resetConfig();
|
||||
message.success('配置已重置');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportConfig = () => {
|
||||
const configData = JSON.stringify(config, null, 2);
|
||||
const blob = new Blob([configData], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `kra-config-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('配置已导出');
|
||||
};
|
||||
|
||||
const handleImportConfig = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const importedConfig = JSON.parse(e.target?.result as string);
|
||||
updateConfig(importedConfig);
|
||||
message.success('配置已导入');
|
||||
} catch {
|
||||
message.error('配置文件格式错误');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kra-setting-content">
|
||||
{/* System Info */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="系统信息" />
|
||||
<div className="kra-setting-card">
|
||||
<div className="kra-system-info-grid">
|
||||
<div className="kra-system-info-item">
|
||||
<span className="label">版本</span>
|
||||
<span className="value">v1.0.0</span>
|
||||
</div>
|
||||
<div className="kra-system-info-item">
|
||||
<span className="label">前端框架</span>
|
||||
<span className="value">React 19</span>
|
||||
</div>
|
||||
<div className="kra-system-info-item">
|
||||
<span className="label">UI 组件库</span>
|
||||
<span className="value">Ant Design</span>
|
||||
</div>
|
||||
<div className="kra-system-info-item">
|
||||
<span className="label">构建工具</span>
|
||||
<span className="value">UMI</span>
|
||||
</div>
|
||||
<div className="kra-system-info-item">
|
||||
<span className="label">浏览器</span>
|
||||
<span className="value">{browserInfo}</span>
|
||||
</div>
|
||||
<div className="kra-system-info-item">
|
||||
<span className="label">屏幕分辨率</span>
|
||||
<span className="value">{screenResolution}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config Management */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="配置管理" />
|
||||
<div className="kra-setting-card">
|
||||
<div className="kra-config-action-item">
|
||||
<div className="kra-config-action-icon danger">
|
||||
<ReloadOutlined />
|
||||
</div>
|
||||
<div className="kra-config-action-info">
|
||||
<h4>重置配置</h4>
|
||||
<p>将所有设置恢复为默认值</p>
|
||||
</div>
|
||||
<Button danger onClick={handleResetConfig}>
|
||||
重置配置
|
||||
</Button>
|
||||
</div>
|
||||
<div className="kra-config-action-item">
|
||||
<div className="kra-config-action-icon primary">
|
||||
<ExportOutlined />
|
||||
</div>
|
||||
<div className="kra-config-action-info">
|
||||
<h4>导出配置</h4>
|
||||
<p>导出当前配置为 JSON 文件</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={handleExportConfig}>
|
||||
导出配置
|
||||
</Button>
|
||||
</div>
|
||||
<div className="kra-config-action-item">
|
||||
<div className="kra-config-action-icon success">
|
||||
<ImportOutlined />
|
||||
</div>
|
||||
<div className="kra-config-action-info">
|
||||
<h4>导入配置</h4>
|
||||
<p>从 JSON 文件导入配置</p>
|
||||
</div>
|
||||
<Upload
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleImportConfig}
|
||||
>
|
||||
<Button style={{ backgroundColor: '#52c41a', borderColor: '#52c41a', color: '#fff' }}>
|
||||
导入配置
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About Project */}
|
||||
<div className="kra-setting-section">
|
||||
<SectionHeader title="关于项目" />
|
||||
<div className="kra-setting-card">
|
||||
<div className="kra-about-project">
|
||||
<div className="kra-about-logo">
|
||||
<Logo size={48} />
|
||||
</div>
|
||||
<div className="kra-about-info">
|
||||
<h4>Kratos Admin</h4>
|
||||
<p>基于 React 19 + Go Kratos 的全栈开发基础平台,提供完整的后台管理解决方案</p>
|
||||
<div className="kra-about-links">
|
||||
<a href="https://github.com/go-kratos/kratos" target="_blank" rel="noopener noreferrer">
|
||||
<GithubOutlined /> GitHub 仓库
|
||||
</a>
|
||||
<span className="divider">·</span>
|
||||
<a href="https://go-kratos.dev/" target="_blank" rel="noopener noreferrer">
|
||||
<BookOutlined /> 官方文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Setting Drawer Component
|
||||
const SettingDrawer: React.FC<SettingDrawerProps> = ({ open, onClose }) => {
|
||||
const { config, resetConfig } = useAppStore();
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'appearance',
|
||||
label: '外观',
|
||||
children: <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
key: 'layout',
|
||||
label: '布局',
|
||||
children: <LayoutSettings />,
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
label: '通用',
|
||||
children: <GeneralSettings />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<div className="kra-setting-drawer-header">
|
||||
<span>系统配置</span>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={resetConfig}
|
||||
style={{ backgroundColor: config.primaryColor, borderColor: config.primaryColor }}
|
||||
>
|
||||
重置配置
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={500}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="kra-setting-drawer"
|
||||
>
|
||||
<Tabs
|
||||
defaultActiveKey="appearance"
|
||||
items={tabItems}
|
||||
centered
|
||||
className="kra-setting-tabs"
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingDrawer;
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* KRA - Sidebar Component
|
||||
* Collapsible sidebar menu with multi-level navigation
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Menu, Layout } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useModel, history, useLocation } from '@umijs/max';
|
||||
import * as Icons from '@ant-design/icons';
|
||||
import { useCollapsed, useConfig, useAsyncRouters } from '@/models';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface MenuItem {
|
||||
ID?: number;
|
||||
name: string;
|
||||
path: string;
|
||||
hidden?: boolean;
|
||||
component?: string;
|
||||
meta?: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
keepAlive?: boolean;
|
||||
defaultMenu?: boolean;
|
||||
closeTab?: boolean;
|
||||
activeName?: string;
|
||||
};
|
||||
children?: MenuItem[];
|
||||
parameters?: Array<{ type: string; key: string; value: string }>;
|
||||
}
|
||||
|
||||
// Get icon component by name
|
||||
const getIcon = (iconName?: string): React.ReactNode => {
|
||||
if (!iconName) return null;
|
||||
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
|
||||
return IconComponent ? <IconComponent /> : null;
|
||||
};
|
||||
|
||||
// Convert menu data to Ant Design menu items
|
||||
const convertToMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
|
||||
return menus
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
children: convertToMenuItems(item.children!),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
/** Custom width */
|
||||
width?: number;
|
||||
/** Collapsed width */
|
||||
collapsedWidth?: number;
|
||||
/** Theme */
|
||||
theme?: 'light' | 'dark';
|
||||
/** Show logo */
|
||||
showLogo?: boolean;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
width,
|
||||
collapsedWidth,
|
||||
theme = 'dark',
|
||||
showLogo = true,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const collapsed = useCollapsed();
|
||||
const config = useConfig();
|
||||
const asyncRouters = useAsyncRouters();
|
||||
const { routeMap } = useModel('router');
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||
|
||||
// Get menu items from async routers
|
||||
const menuItems = useMemo(() => {
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (!rootMenu?.children) return [];
|
||||
return convertToMenuItems(rootMenu.children);
|
||||
}, [asyncRouters]);
|
||||
|
||||
// Find parent keys for a menu item
|
||||
const findParentKeys = (name: string, menus: MenuItem[], parents: string[] = []): string[] => {
|
||||
for (const menu of menus) {
|
||||
if (menu.name === name) {
|
||||
return parents;
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findParentKeys(name, menu.children, [...parents, menu.name]);
|
||||
if (found.length > 0 || menu.children.some((c) => c.name === name)) {
|
||||
return found.length > 0 ? found : [...parents, menu.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Update selected keys based on current route
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
// Find the menu item that matches the current path
|
||||
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === pathname || `/${menu.path}` === pathname) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findMenuByPath(menu.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (rootMenu?.children) {
|
||||
const currentMenu = findMenuByPath(rootMenu.children);
|
||||
if (currentMenu) {
|
||||
setSelectedKeys([currentMenu.name]);
|
||||
const parentKeys = findParentKeys(currentMenu.name, rootMenu.children);
|
||||
if (parentKeys.length > 0 && !collapsed) {
|
||||
setOpenKeys(parentKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.pathname, asyncRouters, collapsed]);
|
||||
|
||||
// Handle menu click
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
const menuItem = routeMap?.[key];
|
||||
if (!menuItem) {
|
||||
history.push(`/${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle external links
|
||||
if (key.startsWith('http://') || key.startsWith('https://')) {
|
||||
window.open(key, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build query and params from menu parameters
|
||||
const query: Record<string, string> = {};
|
||||
const params: Record<string, string> = {};
|
||||
menuItem.parameters?.forEach((param: any) => {
|
||||
if (param.type === 'query') {
|
||||
query[param.key] = param.value;
|
||||
} else {
|
||||
params[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
|
||||
history.push({
|
||||
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
|
||||
query,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle open keys change
|
||||
const handleOpenChange = (keys: string[]) => {
|
||||
setOpenKeys(keys);
|
||||
};
|
||||
|
||||
const siderWidth = width || config?.sideWidth || 256;
|
||||
const siderCollapsedWidth = collapsedWidth || config?.sideCollapsedWidth || 80;
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={siderWidth}
|
||||
collapsedWidth={siderCollapsedWidth}
|
||||
theme={theme}
|
||||
trigger={null}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{showLogo && (
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: collapsed ? '0 8px' : '0 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="KRA"
|
||||
style={{ height: 32, marginRight: collapsed ? 0 : 8 }}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Kratos Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
theme={theme}
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
openKeys={collapsed ? [] : openKeys}
|
||||
onOpenChange={handleOpenChange}
|
||||
onClick={handleMenuClick}
|
||||
items={menuItems}
|
||||
style={{ borderRight: 0 }}
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* KRA - Tabs Component
|
||||
* Tab-based page management
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs as AntTabs, Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useLocation, history } from '@umijs/max';
|
||||
import { CloseOutlined, ReloadOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
label: string;
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
const Tabs: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const [activeKey, setActiveKey] = useState(location.pathname);
|
||||
const [tabs, setTabs] = useState<TabItem[]>([
|
||||
{ key: '/dashboard', label: '首页', closable: false },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const path = location.pathname;
|
||||
setActiveKey(path);
|
||||
|
||||
// Add new tab if not exists
|
||||
if (!tabs.find((tab) => tab.key === path) && path !== '/') {
|
||||
const title = document.title?.replace(' - Kratos Admin', '') || path;
|
||||
setTabs((prev) => [...prev, { key: path, label: title, closable: true }]);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveKey(key);
|
||||
history.push(key);
|
||||
};
|
||||
|
||||
const handleTabEdit = (targetKey: string | React.MouseEvent | React.KeyboardEvent, action: 'add' | 'remove') => {
|
||||
if (action === 'remove' && typeof targetKey === 'string') {
|
||||
const newTabs = tabs.filter((tab) => tab.key !== targetKey);
|
||||
setTabs(newTabs);
|
||||
|
||||
// Navigate to previous tab if current tab is closed
|
||||
if (targetKey === activeKey && newTabs.length > 0) {
|
||||
const lastTab = newTabs[newTabs.length - 1];
|
||||
setActiveKey(lastTab.key);
|
||||
history.push(lastTab.key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeOthers = () => {
|
||||
const newTabs = tabs.filter((tab) => !tab.closable || tab.key === activeKey);
|
||||
setTabs(newTabs);
|
||||
};
|
||||
|
||||
const closeAll = () => {
|
||||
const newTabs = tabs.filter((tab) => !tab.closable);
|
||||
setTabs(newTabs);
|
||||
if (newTabs.length > 0) {
|
||||
setActiveKey(newTabs[0].key);
|
||||
history.push(newTabs[0].key);
|
||||
}
|
||||
};
|
||||
|
||||
const contextMenuItems: MenuProps['items'] = [
|
||||
{ key: 'reload', icon: <ReloadOutlined />, label: '刷新当前' },
|
||||
{ key: 'closeOthers', icon: <CloseCircleOutlined />, label: '关闭其他' },
|
||||
{ key: 'closeAll', icon: <CloseOutlined />, label: '关闭所有' },
|
||||
];
|
||||
|
||||
const handleContextMenu: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'reload':
|
||||
history.push(activeKey);
|
||||
break;
|
||||
case 'closeOthers':
|
||||
closeOthers();
|
||||
break;
|
||||
case 'closeAll':
|
||||
closeAll();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: contextMenuItems, onClick: handleContextMenu }} trigger={['contextMenu']}>
|
||||
<div style={{ padding: '0 16px', background: '#fff', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<AntTabs
|
||||
type="editable-card"
|
||||
hideAdd
|
||||
activeKey={activeKey}
|
||||
onChange={handleTabChange}
|
||||
onEdit={handleTabEdit}
|
||||
items={tabs.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
closable: tab.closable,
|
||||
}))}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* KRA - Layout Components Index
|
||||
*/
|
||||
export { default as HeaderTools } from './HeaderTools';
|
||||
export { default as Screenfull } from './Screenfull';
|
||||
export { default as Search } from './Search';
|
||||
export { default as SettingDrawer } from './SettingDrawer';
|
||||
export { default as Sidebar } from './Sidebar';
|
||||
export { default as Tabs } from './Tabs';
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* KRA - Layouts Export
|
||||
*/
|
||||
export { default as BasicLayout } from './BasicLayout';
|
||||
export { default as UserLayout } from './UserLayout';
|
||||
export { default as BlankLayout } from './BlankLayout';
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* KRA - Combination Mode Layout
|
||||
* Combined top + side navigation layout
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import { useModel, history, useLocation } from '@umijs/max';
|
||||
import * as Icons from '@ant-design/icons';
|
||||
import { useCollapsed, useConfig, useAsyncRouters, useTopMenu, useLeftMenu, useAppStore } from '@/models';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface MenuItem {
|
||||
name: string;
|
||||
path: string;
|
||||
hidden?: boolean;
|
||||
meta?: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
activeName?: string;
|
||||
};
|
||||
children?: MenuItem[];
|
||||
parameters?: Array<{ type: string; key: string; value: string }>;
|
||||
}
|
||||
|
||||
// Get icon component by name
|
||||
const getIcon = (iconName?: string): React.ReactNode => {
|
||||
if (!iconName) return null;
|
||||
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
|
||||
return IconComponent ? <IconComponent /> : null;
|
||||
};
|
||||
|
||||
// Convert menu data to Ant Design menu items (top level only for horizontal)
|
||||
const convertToTopMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
|
||||
return menus
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => ({
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
}));
|
||||
};
|
||||
|
||||
// Convert menu data to Ant Design menu items (with children for sidebar)
|
||||
const convertToSideMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
|
||||
return menus
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
children: convertToSideMenuItems(item.children!),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface CombinationModeProps {
|
||||
/** Which part to render: 'head' for top menu, 'normal' for side menu */
|
||||
mode: 'head' | 'normal';
|
||||
/** Theme */
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const CombinationMode: React.FC<CombinationModeProps> = ({ mode, theme = 'dark' }) => {
|
||||
const location = useLocation();
|
||||
const collapsed = useCollapsed();
|
||||
const config = useConfig();
|
||||
const asyncRouters = useAsyncRouters();
|
||||
const topMenu = useTopMenu();
|
||||
const leftMenu = useLeftMenu();
|
||||
const { setCollapsed, setLeftMenu, setTopActive } = useAppStore();
|
||||
const { routeMap, topActive } = useModel('router');
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||
|
||||
// Get top menu items
|
||||
const topMenuItems = useMemo(() => {
|
||||
if (!topMenu?.length) {
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (!rootMenu?.children) return [];
|
||||
return convertToTopMenuItems(rootMenu.children);
|
||||
}
|
||||
return convertToTopMenuItems(topMenu);
|
||||
}, [topMenu, asyncRouters]);
|
||||
|
||||
// Get side menu items
|
||||
const sideMenuItems = useMemo(() => {
|
||||
if (!leftMenu?.length) return [];
|
||||
return convertToSideMenuItems(leftMenu);
|
||||
}, [leftMenu]);
|
||||
|
||||
// Find parent keys for a menu item
|
||||
const findParentKeys = (name: string, menus: MenuItem[], parents: string[] = []): string[] => {
|
||||
for (const menu of menus) {
|
||||
if (menu.name === name) {
|
||||
return parents;
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findParentKeys(name, menu.children, [...parents, menu.name]);
|
||||
if (found.length > 0 || menu.children.some((c) => c.name === name)) {
|
||||
return found.length > 0 ? found : [...parents, menu.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Update selected keys based on current route
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === pathname || `/${menu.path}` === pathname) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findMenuByPath(menu.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (leftMenu?.length) {
|
||||
const currentMenu = findMenuByPath(leftMenu);
|
||||
if (currentMenu) {
|
||||
setSelectedKeys([currentMenu.name]);
|
||||
const parentKeys = findParentKeys(currentMenu.name, leftMenu);
|
||||
if (parentKeys.length > 0 && !collapsed) {
|
||||
setOpenKeys(parentKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.pathname, leftMenu, collapsed]);
|
||||
|
||||
// Handle top menu click
|
||||
const handleTopMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
setTopActive?.(key);
|
||||
|
||||
// Find the menu item and set left menu
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
const topMenuItem = rootMenu?.children?.find((item: MenuItem) => item.name === key);
|
||||
|
||||
if (topMenuItem?.children?.length) {
|
||||
setLeftMenu?.(topMenuItem.children);
|
||||
// Navigate to first visible child
|
||||
const firstChild = topMenuItem.children.find((c: MenuItem) => !c.hidden);
|
||||
if (firstChild) {
|
||||
navigateToMenu(firstChild.name);
|
||||
}
|
||||
} else {
|
||||
setLeftMenu?.([]);
|
||||
navigateToMenu(key);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle side menu click
|
||||
const handleSideMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigateToMenu(key);
|
||||
};
|
||||
|
||||
// Navigate to menu
|
||||
const navigateToMenu = (key: string) => {
|
||||
const menuItem = routeMap?.[key];
|
||||
if (!menuItem) {
|
||||
history.push(`/${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.startsWith('http://') || key.startsWith('https://')) {
|
||||
window.open(key, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
const params: Record<string, string> = {};
|
||||
menuItem.parameters?.forEach((param: any) => {
|
||||
if (param.type === 'query') {
|
||||
query[param.key] = param.value;
|
||||
} else {
|
||||
params[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
|
||||
history.push({
|
||||
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
|
||||
query,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (keys: string[]) => {
|
||||
setOpenKeys(keys);
|
||||
};
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
const siderWidth = config?.sideWidth || 256;
|
||||
const siderCollapsedWidth = config?.sideCollapsedWidth || 80;
|
||||
|
||||
// Render top menu (horizontal)
|
||||
if (mode === 'head') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
mode="horizontal"
|
||||
selectedKeys={topActive ? [topActive] : []}
|
||||
onClick={handleTopMenuClick}
|
||||
items={topMenuItems}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
lineHeight: '62px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render side menu (vertical)
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={siderWidth}
|
||||
collapsedWidth={siderCollapsedWidth}
|
||||
theme={theme}
|
||||
trigger={null}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
theme={theme}
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
openKeys={collapsed ? [] : openKeys}
|
||||
onOpenChange={handleOpenChange}
|
||||
onClick={handleSideMenuClick}
|
||||
items={sideMenuItems}
|
||||
style={{ borderRight: 0, height: 'calc(100% - 48px)' }}
|
||||
/>
|
||||
|
||||
{/* Collapse Toggle Button */}
|
||||
<div
|
||||
onClick={toggleCollapse}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.1)' : '#f5f5f5',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: theme === 'dark' ? '#fff' : '#333',
|
||||
}}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CombinationMode;
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* KRA - Head Mode Layout
|
||||
* Top navigation layout with horizontal menu
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useModel, history, useLocation } from '@umijs/max';
|
||||
import * as Icons from '@ant-design/icons';
|
||||
import { useAsyncRouters } from '@/models';
|
||||
|
||||
interface MenuItem {
|
||||
name: string;
|
||||
path: string;
|
||||
hidden?: boolean;
|
||||
meta?: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
activeName?: string;
|
||||
};
|
||||
children?: MenuItem[];
|
||||
parameters?: Array<{ type: string; key: string; value: string }>;
|
||||
}
|
||||
|
||||
// Get icon component by name
|
||||
const getIcon = (iconName?: string): React.ReactNode => {
|
||||
if (!iconName) return null;
|
||||
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
|
||||
return IconComponent ? <IconComponent /> : null;
|
||||
};
|
||||
|
||||
// Convert menu data to Ant Design menu items
|
||||
const convertToMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
|
||||
return menus
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
children: convertToMenuItems(item.children!),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface HeadModeProps {
|
||||
/** Theme */
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const HeadMode: React.FC<HeadModeProps> = ({ theme = 'light' }) => {
|
||||
const location = useLocation();
|
||||
const asyncRouters = useAsyncRouters();
|
||||
const { routeMap } = useModel('router');
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
|
||||
// Get menu items from async routers
|
||||
const menuItems = useMemo(() => {
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (!rootMenu?.children) return [];
|
||||
return convertToMenuItems(rootMenu.children);
|
||||
}, [asyncRouters]);
|
||||
|
||||
// Update selected keys based on current route
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === pathname || `/${menu.path}` === pathname) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findMenuByPath(menu.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (rootMenu?.children) {
|
||||
const currentMenu = findMenuByPath(rootMenu.children);
|
||||
if (currentMenu) {
|
||||
setSelectedKeys([currentMenu.name]);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, asyncRouters]);
|
||||
|
||||
// Handle menu click
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
const menuItem = routeMap?.[key];
|
||||
if (!menuItem) {
|
||||
history.push(`/${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.startsWith('http://') || key.startsWith('https://')) {
|
||||
window.open(key, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
const params: Record<string, string> = {};
|
||||
menuItem.parameters?.forEach((param: any) => {
|
||||
if (param.type === 'query') {
|
||||
query[param.key] = param.value;
|
||||
} else {
|
||||
params[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
|
||||
history.push({
|
||||
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
|
||||
query,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
theme={theme}
|
||||
mode="horizontal"
|
||||
selectedKeys={selectedKeys}
|
||||
onClick={handleMenuClick}
|
||||
items={menuItems}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
lineHeight: '62px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeadMode;
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* KRA - Normal Mode Layout
|
||||
* Standard sidebar layout with collapsible menu
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import { useModel, history, useLocation } from '@umijs/max';
|
||||
import * as Icons from '@ant-design/icons';
|
||||
import { useCollapsed, useConfig, useAsyncRouters, useAppStore } from '@/models';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface MenuItem {
|
||||
name: string;
|
||||
path: string;
|
||||
hidden?: boolean;
|
||||
meta?: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
activeName?: string;
|
||||
};
|
||||
children?: MenuItem[];
|
||||
parameters?: Array<{ type: string; key: string; value: string }>;
|
||||
}
|
||||
|
||||
// Get icon component by name
|
||||
const getIcon = (iconName?: string): React.ReactNode => {
|
||||
if (!iconName) return null;
|
||||
const IconComponent = (Icons as any)[iconName] || (Icons as any)[`${iconName}Outlined`];
|
||||
return IconComponent ? <IconComponent /> : null;
|
||||
};
|
||||
|
||||
// Convert menu data to Ant Design menu items
|
||||
const convertToMenuItems = (menus: MenuItem[]): MenuProps['items'] => {
|
||||
return menus
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const hasChildren = item.children && item.children.filter((c) => !c.hidden).length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
children: convertToMenuItems(item.children!),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: item.name,
|
||||
icon: getIcon(item.meta?.icon),
|
||||
label: item.meta?.title || item.name,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface NormalModeProps {
|
||||
/** Theme */
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const NormalMode: React.FC<NormalModeProps> = ({ theme = 'dark' }) => {
|
||||
const location = useLocation();
|
||||
const collapsed = useCollapsed();
|
||||
const config = useConfig();
|
||||
const asyncRouters = useAsyncRouters();
|
||||
const { setCollapsed } = useAppStore();
|
||||
const { routeMap } = useModel('router');
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||
|
||||
// Get menu items from async routers
|
||||
const menuItems = useMemo(() => {
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (!rootMenu?.children) return [];
|
||||
return convertToMenuItems(rootMenu.children);
|
||||
}, [asyncRouters]);
|
||||
|
||||
// Find parent keys for a menu item
|
||||
const findParentKeys = (name: string, menus: MenuItem[], parents: string[] = []): string[] => {
|
||||
for (const menu of menus) {
|
||||
if (menu.name === name) {
|
||||
return parents;
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findParentKeys(name, menu.children, [...parents, menu.name]);
|
||||
if (found.length > 0 || menu.children.some((c) => c.name === name)) {
|
||||
return found.length > 0 ? found : [...parents, menu.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Update selected keys based on current route
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
const findMenuByPath = (menus: MenuItem[]): MenuItem | null => {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === pathname || `/${menu.path}` === pathname) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findMenuByPath(menu.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const rootMenu = asyncRouters?.[0];
|
||||
if (rootMenu?.children) {
|
||||
const currentMenu = findMenuByPath(rootMenu.children);
|
||||
if (currentMenu) {
|
||||
setSelectedKeys([currentMenu.name]);
|
||||
const parentKeys = findParentKeys(currentMenu.name, rootMenu.children);
|
||||
if (parentKeys.length > 0 && !collapsed) {
|
||||
setOpenKeys(parentKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.pathname, asyncRouters, collapsed]);
|
||||
|
||||
// Handle menu click
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
const menuItem = routeMap?.[key];
|
||||
if (!menuItem) {
|
||||
history.push(`/${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.startsWith('http://') || key.startsWith('https://')) {
|
||||
window.open(key, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
const params: Record<string, string> = {};
|
||||
menuItem.parameters?.forEach((param: any) => {
|
||||
if (param.type === 'query') {
|
||||
query[param.key] = param.value;
|
||||
} else {
|
||||
params[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
|
||||
history.push({
|
||||
pathname: menuItem.path?.startsWith('/') ? menuItem.path : `/${menuItem.path}`,
|
||||
query,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (keys: string[]) => {
|
||||
setOpenKeys(keys);
|
||||
};
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
const siderWidth = config?.sideWidth || 256;
|
||||
const siderCollapsedWidth = config?.sideCollapsedWidth || 80;
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={siderWidth}
|
||||
collapsedWidth={siderCollapsedWidth}
|
||||
theme={theme}
|
||||
trigger={null}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
theme={theme}
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
openKeys={collapsed ? [] : openKeys}
|
||||
onOpenChange={handleOpenChange}
|
||||
onClick={handleMenuClick}
|
||||
items={menuItems}
|
||||
style={{ borderRight: 0, height: 'calc(100% - 48px)' }}
|
||||
/>
|
||||
|
||||
{/* Collapse Toggle Button */}
|
||||
<div
|
||||
onClick={toggleCollapse}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.1)' : '#f5f5f5',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: theme === 'dark' ? '#fff' : '#333',
|
||||
}}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default NormalMode;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* KRA - Layout Modes Export
|
||||
*/
|
||||
export { default as NormalMode } from './NormalMode';
|
||||
export { default as HeadMode } from './HeadMode';
|
||||
export { default as CombinationMode } from './CombinationMode';
|
||||
Reference in New Issue
Block a user