前端重构

This commit is contained in:
Yvan
2026-01-08 11:20:29 +08:00
parent d76655a30a
commit f12f2e0f00
201 changed files with 27517 additions and 537 deletions
+91
View File
@@ -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;
+12
View File
@@ -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;
+37
View File
@@ -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);
}
}
+29
View File
@@ -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;
+105
View File
@@ -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;
+50
View File
@@ -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;
+235
View File
@@ -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;
+243
View File
@@ -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;
+109
View File
@@ -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;
+9
View File
@@ -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';
+6
View File
@@ -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';
+295
View File
@@ -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;
+155
View File
@@ -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;
+217
View File
@@ -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;
+6
View File
@@ -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';