424 lines
14 KiB
JavaScript
424 lines
14 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Layout, Menu, Button, Avatar, Space, Drawer, Grid, Badge, Breadcrumb, Tooltip } from 'antd';
|
|
import {
|
|
UserOutlined,
|
|
LogoutOutlined,
|
|
MenuFoldOutlined,
|
|
MenuUnfoldOutlined,
|
|
RightOutlined,
|
|
} from '@ant-design/icons';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import menuService from '../services/menuService';
|
|
import { renderMenuIcon } from '../utils/menuIcons';
|
|
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
|
|
|
|
const { Header, Content, Sider } = Layout;
|
|
const { useBreakpoint } = Grid;
|
|
|
|
const MainLayout = ({ children, user, onLogout }) => {
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [userMenus, setUserMenus] = useState([]);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [openKeys, setOpenKeys] = useState([]);
|
|
const [activeMenuKey, setActiveMenuKey] = useState(null);
|
|
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const screens = useBreakpoint();
|
|
|
|
const isMobile = !screens.lg;
|
|
|
|
useEffect(() => {
|
|
const fetchMenus = async () => {
|
|
try {
|
|
const response = await menuService.getUserMenus();
|
|
if (response.code === '200') {
|
|
setUserMenus(response.data.menus || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching menus:', error);
|
|
}
|
|
};
|
|
fetchMenus();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
configService.getBrandingConfig().then(setBranding).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isMobile) {
|
|
setCollapsed(false);
|
|
}
|
|
}, [isMobile]);
|
|
|
|
const menuItems = useMemo(() => {
|
|
const sortedMenus = [...userMenus]
|
|
.sort((a, b) => {
|
|
if ((a.parent_id || 0) !== (b.parent_id || 0)) {
|
|
return (a.parent_id || 0) - (b.parent_id || 0);
|
|
}
|
|
if ((a.sort_order || 0) !== (b.sort_order || 0)) {
|
|
return (a.sort_order || 0) - (b.sort_order || 0);
|
|
}
|
|
return a.menu_id - b.menu_id;
|
|
});
|
|
|
|
const menuById = new Map();
|
|
sortedMenus.forEach((menu) => {
|
|
menuById.set(menu.menu_id, {
|
|
...menu,
|
|
key: `menu_${menu.menu_id}`,
|
|
icon: renderMenuIcon(menu.menu_icon, menu.menu_code),
|
|
children: [],
|
|
});
|
|
});
|
|
|
|
const roots = [];
|
|
menuById.forEach((menu) => {
|
|
if (menu.parent_id && menuById.has(menu.parent_id)) {
|
|
menuById.get(menu.parent_id).children.push(menu);
|
|
} else {
|
|
roots.push(menu);
|
|
}
|
|
});
|
|
|
|
const toMenuItem = (menu) => {
|
|
const hasChildren = menu.children.length > 0;
|
|
return {
|
|
key: menu.key,
|
|
menu_code: menu.menu_code,
|
|
icon: menu.icon,
|
|
label: menu.menu_name,
|
|
path: hasChildren ? null : menu.menu_url,
|
|
children: hasChildren ? menu.children.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)).map(toMenuItem) : undefined,
|
|
onClick: () => {
|
|
if (menu.menu_type === 'link' && menu.menu_url && !hasChildren) {
|
|
setActiveMenuKey(`menu_${menu.menu_id}`);
|
|
navigate(menu.menu_url);
|
|
setDrawerOpen(false);
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
return roots
|
|
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
|
|
.map(toMenuItem);
|
|
}, [navigate, onLogout, userMenus]);
|
|
|
|
const flatMenuKeys = useMemo(() => {
|
|
const keys = [];
|
|
const walk = (items) => {
|
|
items.forEach((item) => {
|
|
keys.push(item.key);
|
|
if (item.children?.length) {
|
|
walk(item.children);
|
|
}
|
|
});
|
|
};
|
|
walk(menuItems);
|
|
return keys;
|
|
}, [menuItems]);
|
|
|
|
const pathKeyEntries = useMemo(() => {
|
|
const entries = [];
|
|
const walk = (items) => {
|
|
items.forEach((item) => {
|
|
if (item.path && String(item.path).startsWith('/')) {
|
|
entries.push({ path: item.path, key: item.key, menuCode: item.menu_code, label: item.label });
|
|
}
|
|
if (item.children?.length) {
|
|
walk(item.children);
|
|
}
|
|
});
|
|
};
|
|
walk(menuItems);
|
|
return entries;
|
|
}, [menuItems]);
|
|
|
|
const keyParentMap = useMemo(() => {
|
|
const map = new Map();
|
|
const walk = (items, parentKey = null) => {
|
|
items.forEach((item) => {
|
|
if (parentKey) {
|
|
map.set(item.key, parentKey);
|
|
}
|
|
if (item.children?.length) {
|
|
walk(item.children, item.key);
|
|
}
|
|
});
|
|
};
|
|
walk(menuItems);
|
|
return map;
|
|
}, [menuItems]);
|
|
|
|
const menuItemByKey = useMemo(() => {
|
|
const map = new Map();
|
|
const walk = (items) => {
|
|
items.forEach((item) => {
|
|
map.set(item.key, item);
|
|
if (item.children?.length) {
|
|
walk(item.children);
|
|
}
|
|
});
|
|
};
|
|
walk(menuItems);
|
|
return map;
|
|
}, [menuItems]);
|
|
|
|
useEffect(() => {
|
|
if (activeMenuKey && !flatMenuKeys.includes(activeMenuKey)) {
|
|
setActiveMenuKey(null);
|
|
}
|
|
}, [activeMenuKey, flatMenuKeys]);
|
|
|
|
const selectedMenuKey = useMemo(() => {
|
|
if (!pathKeyEntries.length) return [];
|
|
|
|
const exactMatches = pathKeyEntries.filter((item) => item.path === location.pathname);
|
|
if (exactMatches.length) {
|
|
if (activeMenuKey && exactMatches.some((item) => item.key === activeMenuKey)) {
|
|
return [activeMenuKey];
|
|
}
|
|
return [exactMatches[0].key];
|
|
}
|
|
|
|
const prefixMatches = [...pathKeyEntries]
|
|
.sort((a, b) => String(b.path).length - String(a.path).length)
|
|
.filter((item) => location.pathname.startsWith(String(item.path)));
|
|
|
|
if (prefixMatches.length) {
|
|
if (activeMenuKey && prefixMatches.some((item) => item.key === activeMenuKey)) {
|
|
return [activeMenuKey];
|
|
}
|
|
return [prefixMatches[0].key];
|
|
}
|
|
|
|
return [];
|
|
}, [activeMenuKey, location.pathname, pathKeyEntries]);
|
|
|
|
useEffect(() => {
|
|
const currentKey = selectedMenuKey[0];
|
|
if (!currentKey) return;
|
|
|
|
const ancestors = [];
|
|
const visited = new Set();
|
|
let cursor = keyParentMap.get(currentKey);
|
|
while (cursor && !visited.has(cursor)) {
|
|
visited.add(cursor);
|
|
ancestors.unshift(cursor);
|
|
cursor = keyParentMap.get(cursor);
|
|
}
|
|
|
|
setOpenKeys((prev) => {
|
|
const next = Array.from(new Set([...prev, ...ancestors]));
|
|
if (next.length === prev.length && next.every((item, index) => item === prev[index])) {
|
|
return prev;
|
|
}
|
|
return next;
|
|
});
|
|
}, [keyParentMap, selectedMenuKey]);
|
|
|
|
const breadcrumbItems = useMemo(() => {
|
|
const create = (title, path) => ({ title, path });
|
|
const path = location.pathname;
|
|
const activeMenuItem = selectedMenuKey[0] ? menuItemByKey.get(selectedMenuKey[0]) : null;
|
|
|
|
if (path === '/dashboard') return [create(activeMenuItem?.label || '工作台')];
|
|
if (path === '/prompt-management') return [create('平台管理', '/admin/management'), create('提示词仓库')];
|
|
if (path === '/prompt-config') {
|
|
return user?.role_id === 1
|
|
? [create('平台管理', '/admin/management'), create('提示词配置')]
|
|
: [create('会议管理'), create('提示词配置')];
|
|
}
|
|
if (path === '/personal-prompts') return [create('会议管理'), create('个人提示词仓库')];
|
|
if (path === '/meetings/center' || path === '/meetings/history') return [create('会议管理'), create('会议中心')];
|
|
if (path === '/knowledge-base') return [create('知识库')];
|
|
if (path.startsWith('/knowledge-base/edit/')) return [create('知识库', '/knowledge-base'), create('编辑知识库')];
|
|
if (path === '/account-settings') return [create('个人设置')];
|
|
if (path.startsWith('/meetings/')) return [create('会议管理', '/meetings/center'), create('会议详情')];
|
|
if (path === '/downloads') return [create('客户端下载')];
|
|
|
|
if (path === '/admin/management') {
|
|
return [create('平台管理')];
|
|
}
|
|
|
|
if (path.startsWith('/admin/management/')) {
|
|
const moduleKey = path.split('/')[3];
|
|
const moduleNameMap = {
|
|
'user-management': '用户管理',
|
|
'permission-management': '权限管理',
|
|
'dict-management': '字典管理',
|
|
'hot-word-management': '热词管理',
|
|
'client-management': '客户端管理',
|
|
'external-app-management': '外部应用管理',
|
|
'terminal-management': '终端管理',
|
|
'parameter-management': '参数管理',
|
|
'model-management': '模型管理',
|
|
};
|
|
const systemModules = new Set(['user-management', 'permission-management', 'dict-management', 'parameter-management']);
|
|
const parentTitle = systemModules.has(moduleKey) ? '系统管理' : '平台管理';
|
|
return [create(parentTitle, '/admin/management'), create(moduleNameMap[moduleKey] || parentTitle)];
|
|
}
|
|
|
|
return [create('当前页面')];
|
|
}, [location.pathname, menuItemByKey, selectedMenuKey, user?.role_id]);
|
|
|
|
const sidebar = (
|
|
<>
|
|
<div className={`main-layout-brand ${collapsed ? 'collapsed' : ''}`}>
|
|
<span className="main-layout-brand-icon">
|
|
<img src="/favicon.svg" alt="iMeeting" className="main-layout-brand-icon-image" />
|
|
</span>
|
|
{!collapsed && (
|
|
<div>
|
|
<div className="main-layout-brand-title">{branding.app_name}</div>
|
|
<div className="main-layout-brand-subtitle">{branding.console_subtitle}</div>
|
|
</div>
|
|
)}
|
|
{!isMobile && (
|
|
<Button
|
|
type="text"
|
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
className="main-layout-brand-toggle"
|
|
/>
|
|
)}
|
|
</div>
|
|
<Menu
|
|
mode="inline"
|
|
selectedKeys={selectedMenuKey}
|
|
openKeys={openKeys}
|
|
onOpenChange={setOpenKeys}
|
|
items={menuItems}
|
|
className="main-layout-menu"
|
|
/>
|
|
<div className={`main-layout-user-panel ${collapsed ? 'collapsed' : ''}`}>
|
|
<div
|
|
className="main-layout-user-card"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => navigate('/account-settings')}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
navigate('/account-settings');
|
|
}
|
|
}}
|
|
>
|
|
<Avatar
|
|
src={user.avatar_url}
|
|
icon={!user.avatar_url && <UserOutlined />}
|
|
className="main-layout-user-avatar"
|
|
/>
|
|
{!collapsed && (
|
|
<div className="main-layout-user-meta">
|
|
<div className="main-layout-user-name">{user.caption || user.username || '用户'}</div>
|
|
<div className="main-layout-user-role">
|
|
{user.role_name ? String(user.role_name).toUpperCase() : user.role_id === 1 ? 'ADMIN' : 'USER'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Tooltip title="退出系统" placement="top">
|
|
<Button
|
|
type="text"
|
|
icon={<LogoutOutlined />}
|
|
className="main-layout-logout-btn"
|
|
onClick={onLogout}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<Layout className="main-layout-shell">
|
|
{isMobile ? (
|
|
<Drawer
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
placement="left"
|
|
width={280}
|
|
styles={{ body: { padding: 0, background: '#f7fbff' } }}
|
|
closable={false}
|
|
>
|
|
{sidebar}
|
|
</Drawer>
|
|
) : (
|
|
<Sider
|
|
trigger={null}
|
|
collapsible
|
|
collapsed={collapsed}
|
|
theme="light"
|
|
width={252}
|
|
collapsedWidth={84}
|
|
className="main-layout-sider"
|
|
style={{
|
|
boxShadow: '2px 0 18px rgba(31, 78, 146, 0.08)',
|
|
zIndex: 10,
|
|
position: 'fixed',
|
|
height: '100vh',
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
background: 'rgba(255,255,255,0.8)',
|
|
borderRight: '1px solid #dbe6f2',
|
|
}}
|
|
>
|
|
{sidebar}
|
|
</Sider>
|
|
)}
|
|
|
|
<Layout
|
|
style={{
|
|
marginLeft: isMobile ? 0 : collapsed ? 84 : 252,
|
|
transition: 'all 0.25s ease',
|
|
}}
|
|
>
|
|
<Header className="main-layout-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#ffffff' }}>
|
|
<Space align="center" size={14}>
|
|
{isMobile && (
|
|
<Button
|
|
type="text"
|
|
icon={<MenuUnfoldOutlined />}
|
|
onClick={() => setDrawerOpen(true)}
|
|
style={{ fontSize: 18 }}
|
|
/>
|
|
)}
|
|
<Breadcrumb
|
|
className="main-layout-page-path"
|
|
separator={<RightOutlined />}
|
|
items={breadcrumbItems.map((item, index) => {
|
|
const clickable = Boolean(item.path) && index < breadcrumbItems.length - 1;
|
|
return {
|
|
title: clickable ? (
|
|
<a
|
|
href={item.path}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
navigate(item.path);
|
|
}}
|
|
>
|
|
{item.title}
|
|
</a>
|
|
) : (
|
|
item.title
|
|
),
|
|
};
|
|
})}
|
|
/>
|
|
</Space>
|
|
<Space size={14}>
|
|
<Badge color="#22c55e" text="在线" />
|
|
{!isMobile && <span style={{ fontWeight: 600, color: '#3b4f6c' }}>{user.caption}</span>}
|
|
</Space>
|
|
</Header>
|
|
<Content className="main-layout-content">{children}</Content>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default MainLayout;
|