kra/web/src/app.tsx

412 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
};