412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
/**
|
||
* KRA - App Configuration
|
||
* 应用运行时配置
|
||
* 参考 GVA 的 permission.js 和 router store 实现动态路由
|
||
*/
|
||
import { AvatarDropdown, AvatarName, Footer } from '@/components';
|
||
import CommandMenu from '@/components/CommandMenu';
|
||
import { getUserInfo } from '@/services/kratos/user';
|
||
import { asyncMenu } from '@/services/kratos/menu';
|
||
import { getToken } from '@/utils/auth';
|
||
import { asyncRouterHandle, BackendMenuItem } from '@/utils/dynamicRouter';
|
||
import {
|
||
BulbOutlined,
|
||
BulbFilled,
|
||
ReloadOutlined,
|
||
SettingOutlined,
|
||
UserOutlined,
|
||
QuestionCircleOutlined,
|
||
SearchOutlined,
|
||
} from '@ant-design/icons';
|
||
import * as Icons from '@ant-design/icons';
|
||
import type { Settings as LayoutSettings, MenuDataItem } from '@ant-design/pro-components';
|
||
import { SettingDrawer } from '@ant-design/pro-components';
|
||
import type { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
|
||
import { history } from '@umijs/max';
|
||
import { Tooltip } from 'antd';
|
||
import React, { useState } from 'react';
|
||
import defaultSettings from '../config/defaultSettings';
|
||
import { errorConfig } from './requestErrorConfig';
|
||
|
||
const isDev = process.env.NODE_ENV === 'development';
|
||
const loginPath = '/user/login';
|
||
const initPath = '/init';
|
||
const whiteList = [loginPath, initPath, '/error/403', '/error/404', '/error/500', '/scanUpload'];
|
||
|
||
// 图标映射函数
|
||
const iconMap: Record<string, React.ReactNode> = {};
|
||
Object.keys(Icons).forEach((key) => {
|
||
if (key.endsWith('Outlined') || key.endsWith('Filled') || key.endsWith('TwoTone')) {
|
||
const IconComponent = (Icons as any)[key];
|
||
iconMap[key.toLowerCase().replace(/(outlined|filled|twotone)$/, '')] = <IconComponent />;
|
||
}
|
||
});
|
||
|
||
// 自定义图标映射(GVA 使用的图标名称)
|
||
const customIconMap: Record<string, React.ReactNode> = {
|
||
'odometer': <Icons.DashboardOutlined />,
|
||
'user': <Icons.UserOutlined />,
|
||
'avatar': <Icons.TeamOutlined />,
|
||
'tickets': <Icons.MenuOutlined />,
|
||
'platform': <Icons.ApiOutlined />,
|
||
'coordinate': <Icons.UserOutlined />,
|
||
'notebook': <Icons.BookOutlined />,
|
||
'pie-chart': <Icons.PieChartOutlined />,
|
||
'compass': <Icons.CompassOutlined />,
|
||
'tools': <Icons.ToolOutlined />,
|
||
'cpu': <Icons.CodeOutlined />,
|
||
'magic-stick': <Icons.ThunderboltOutlined />,
|
||
'operation': <Icons.SettingOutlined />,
|
||
'folder': <Icons.FolderOutlined />,
|
||
'reading': <Icons.FileTextOutlined />,
|
||
'picture-filled': <Icons.PictureOutlined />,
|
||
'magnet': <Icons.ApiOutlined />,
|
||
'partly-cloudy': <Icons.CloudOutlined />,
|
||
'server': <Icons.CloudServerOutlined />,
|
||
'warn': <Icons.WarningOutlined />,
|
||
'cherry': <Icons.AppstoreOutlined />,
|
||
'shop': <Icons.ShopOutlined />,
|
||
'box': <Icons.InboxOutlined />,
|
||
'files': <Icons.FileOutlined />,
|
||
'message': <Icons.MailOutlined />,
|
||
'scaleToOriginal': <Icons.NotificationOutlined />,
|
||
'upload': <Icons.UploadOutlined />,
|
||
'upload-filled': <Icons.CloudUploadOutlined />,
|
||
'management': <Icons.ExperimentOutlined />,
|
||
'info-filled': <Icons.InfoCircleOutlined />,
|
||
'cloudy': <Icons.CloudOutlined />,
|
||
'setting': <Icons.SettingOutlined />,
|
||
'customer-gva': <Icons.LinkOutlined />,
|
||
};
|
||
|
||
function getIcon(iconName?: string): React.ReactNode {
|
||
if (!iconName) return undefined;
|
||
// 先查自定义映射
|
||
if (customIconMap[iconName]) return customIconMap[iconName];
|
||
// 再查 antd 图标
|
||
const key = iconName.toLowerCase().replace(/-/g, '');
|
||
if (iconMap[key]) return iconMap[key];
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* 将后端菜单数据转换为 ProLayout 菜单格式
|
||
*/
|
||
function convertToMenuData(menus: BackendMenuItem[], parentPath: string = ''): MenuDataItem[] {
|
||
return menus.map((menu) => {
|
||
const path = menu.path.startsWith('/') || menu.path.startsWith('http')
|
||
? menu.path
|
||
: `${parentPath}/${menu.path}`.replace(/\/+/g, '/');
|
||
|
||
const menuItem: MenuDataItem = {
|
||
path,
|
||
name: menu.meta?.title || menu.name,
|
||
icon: getIcon(menu.meta?.icon),
|
||
hideInMenu: menu.hidden,
|
||
};
|
||
|
||
if (menu.children && menu.children.length > 0) {
|
||
menuItem.children = convertToMenuData(menu.children, path);
|
||
}
|
||
|
||
return menuItem;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取初始状态
|
||
* @see https://umijs.org/docs/api/runtime-config#getinitialstate
|
||
*/
|
||
export async function getInitialState(): Promise<{
|
||
settings?: Partial<LayoutSettings>;
|
||
currentUser?: API.CurrentUser;
|
||
loading?: boolean;
|
||
menus?: BackendMenuItem[];
|
||
menuData?: MenuDataItem[];
|
||
fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
|
||
fetchMenus?: () => Promise<BackendMenuItem[]>;
|
||
}> {
|
||
// 获取用户信息
|
||
const fetchUserInfo = async (): Promise<API.CurrentUser | undefined> => {
|
||
try {
|
||
const token = getToken();
|
||
if (!token) {
|
||
return undefined;
|
||
}
|
||
const res = await getUserInfo();
|
||
if (res.code === 0 && res.data?.userInfo) {
|
||
const userInfo = res.data.userInfo;
|
||
return {
|
||
uuid: userInfo.uuid,
|
||
userName: userInfo.userName,
|
||
nickName: userInfo.nickName,
|
||
name: userInfo.nickName || userInfo.userName,
|
||
avatar: userInfo.headerImg,
|
||
headerImg: userInfo.headerImg,
|
||
authority: userInfo.authority,
|
||
authorities: userInfo.authorities,
|
||
buttons: res.data.buttons || [],
|
||
};
|
||
}
|
||
return undefined;
|
||
} catch (error) {
|
||
console.error('获取用户信息失败:', error);
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
// 获取动态菜单 - 对应 GVA 的 SetAsyncRouter
|
||
const fetchMenus = async (): Promise<BackendMenuItem[]> => {
|
||
try {
|
||
const token = getToken();
|
||
if (!token) {
|
||
return [];
|
||
}
|
||
const res = await asyncMenu();
|
||
if (res.code === 0 && res.data?.menus) {
|
||
return res.data.menus;
|
||
}
|
||
return [];
|
||
} catch (error) {
|
||
console.error('获取菜单失败:', error);
|
||
return [];
|
||
}
|
||
};
|
||
|
||
// 如果是白名单页面,不获取用户信息和菜单
|
||
const { location } = history;
|
||
if (whiteList.some((path) => location.pathname.startsWith(path))) {
|
||
return {
|
||
fetchUserInfo,
|
||
fetchMenus,
|
||
settings: defaultSettings as Partial<LayoutSettings>,
|
||
};
|
||
}
|
||
|
||
// 获取用户信息和菜单
|
||
const currentUser = await fetchUserInfo();
|
||
if (!currentUser) {
|
||
history.push(loginPath);
|
||
return {
|
||
fetchUserInfo,
|
||
fetchMenus,
|
||
settings: defaultSettings as Partial<LayoutSettings>,
|
||
};
|
||
}
|
||
|
||
// 获取动态菜单
|
||
const menus = await fetchMenus();
|
||
const menuData = convertToMenuData(menus);
|
||
|
||
return {
|
||
fetchUserInfo,
|
||
fetchMenus,
|
||
currentUser,
|
||
menus,
|
||
menuData,
|
||
settings: defaultSettings as Partial<LayoutSettings>,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* ProLayout 配置
|
||
* @see https://procomponents.ant.design/components/layout
|
||
*/
|
||
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
|
||
const handleRefresh = () => {
|
||
window.location.reload();
|
||
};
|
||
|
||
const handleToggleTheme = () => {
|
||
const newNavTheme = initialState?.settings?.navTheme === 'realDark' ? 'light' : 'realDark';
|
||
setInitialState((preInitialState) => ({
|
||
...preInitialState,
|
||
settings: {
|
||
...preInitialState?.settings,
|
||
navTheme: newNavTheme,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const handleOpenSetting = () => {
|
||
const settingBtn = document.querySelector('.ant-pro-setting-drawer-handle');
|
||
if (settingBtn) {
|
||
(settingBtn as HTMLElement).click();
|
||
}
|
||
};
|
||
|
||
const iconStyle: React.CSSProperties = {
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: '50%',
|
||
border: '1px solid #d9d9d9',
|
||
cursor: 'pointer',
|
||
};
|
||
|
||
return {
|
||
...initialState?.settings,
|
||
|
||
// 使用后端返回的菜单数据
|
||
menu: {
|
||
locale: false, // 禁用国际化
|
||
request: async () => {
|
||
// 返回已获取的菜单数据
|
||
return initialState?.menuData || [];
|
||
},
|
||
},
|
||
|
||
actionsRender: () => {
|
||
const isDarkTheme = initialState?.settings?.navTheme === 'realDark';
|
||
return [
|
||
<Tooltip title="帮助文档" key="doc">
|
||
<a
|
||
href="https://go-kratos.dev/"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
style={{ ...iconStyle, color: 'inherit' }}
|
||
>
|
||
<QuestionCircleOutlined style={{ fontSize: 16 }} />
|
||
</a>
|
||
</Tooltip>,
|
||
<Tooltip title="搜索 (Ctrl+K)" key="search">
|
||
<span
|
||
onClick={() => window.dispatchEvent(new CustomEvent('openCommandMenu'))}
|
||
style={iconStyle}
|
||
>
|
||
<SearchOutlined style={{ fontSize: 16 }} />
|
||
</span>
|
||
</Tooltip>,
|
||
<Tooltip title="系统设置" key="setting">
|
||
<span onClick={handleOpenSetting} style={iconStyle}>
|
||
<SettingOutlined style={{ fontSize: 16 }} />
|
||
</span>
|
||
</Tooltip>,
|
||
<Tooltip title="刷新" key="refresh">
|
||
<span onClick={handleRefresh} style={iconStyle}>
|
||
<ReloadOutlined style={{ fontSize: 16 }} />
|
||
</span>
|
||
</Tooltip>,
|
||
<Tooltip title="切换主题" key="theme">
|
||
<span onClick={handleToggleTheme} style={iconStyle}>
|
||
{isDarkTheme ? (
|
||
<BulbFilled style={{ fontSize: 16, color: '#faad14' }} />
|
||
) : (
|
||
<BulbOutlined style={{ fontSize: 16 }} />
|
||
)}
|
||
</span>
|
||
</Tooltip>,
|
||
];
|
||
},
|
||
|
||
avatarProps: {
|
||
src: initialState?.currentUser?.avatar || initialState?.currentUser?.headerImg || '/default-avatar.svg',
|
||
title: <AvatarName />,
|
||
icon: <UserOutlined />,
|
||
render: (_, avatarChildren) => <AvatarDropdown menu>{avatarChildren}</AvatarDropdown>,
|
||
},
|
||
|
||
waterMarkProps: {
|
||
content: initialState?.currentUser?.nickName || initialState?.currentUser?.userName,
|
||
},
|
||
|
||
footerRender: () => <Footer />,
|
||
|
||
onPageChange: () => {
|
||
const { location } = history;
|
||
if (whiteList.some((path) => location.pathname.startsWith(path))) {
|
||
return;
|
||
}
|
||
if (!initialState?.currentUser) {
|
||
history.push(loginPath);
|
||
}
|
||
},
|
||
|
||
menuHeaderRender: undefined,
|
||
|
||
// 菜单项渲染 - 处理外部链接
|
||
menuItemRender: (item, dom) => {
|
||
if (item.path?.startsWith('http')) {
|
||
return (
|
||
<a href={item.path} target="_blank" rel="noopener noreferrer">
|
||
{dom}
|
||
</a>
|
||
);
|
||
}
|
||
return (
|
||
<a
|
||
onClick={() => {
|
||
if (item.path) {
|
||
history.push(item.path);
|
||
}
|
||
}}
|
||
>
|
||
{dom}
|
||
</a>
|
||
);
|
||
},
|
||
|
||
unAccessible: (
|
||
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||
<h1>403</h1>
|
||
<p>抱歉,您没有权限访问此页面</p>
|
||
</div>
|
||
),
|
||
|
||
childrenRender: (children) => {
|
||
return (
|
||
<>
|
||
{children}
|
||
<CommandMenuWrapper />
|
||
<SettingDrawer
|
||
disableUrlParams
|
||
enableDarkTheme
|
||
settings={initialState?.settings}
|
||
onSettingChange={(settings) => {
|
||
setInitialState((preInitialState) => ({
|
||
...preInitialState,
|
||
settings,
|
||
}));
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
},
|
||
};
|
||
};
|
||
|
||
// CommandMenu包装组件
|
||
const CommandMenuWrapper: React.FC = () => {
|
||
const [open, setOpen] = useState(false);
|
||
|
||
React.useEffect(() => {
|
||
const handleOpen = () => setOpen(true);
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.ctrlKey && e.key === 'k') {
|
||
e.preventDefault();
|
||
setOpen(true);
|
||
}
|
||
};
|
||
window.addEventListener('openCommandMenu', handleOpen);
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => {
|
||
window.removeEventListener('openCommandMenu', handleOpen);
|
||
window.removeEventListener('keydown', handleKeyDown);
|
||
};
|
||
}, []);
|
||
|
||
return <CommandMenu open={open} onClose={() => setOpen(false)} />;
|
||
};
|
||
|
||
/**
|
||
* 请求配置
|
||
* @see https://umijs.org/docs/max/request#配置
|
||
*/
|
||
export const request: RequestConfig = {
|
||
baseURL: isDev ? '/api' : '/api',
|
||
timeout: 30000,
|
||
...errorConfig,
|
||
};
|