imetting/frontend/src/components/MainLayout.jsx

424 lines
14 KiB
React
Raw Normal View History

2026-03-26 06:55:12 +00:00
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';
2026-03-26 06:55:12 +00:00
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);
2026-03-26 06:55:12 +00:00
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(() => {});
}, []);
2026-03-26 06:55:12 +00:00
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>
2026-03-26 06:55:12 +00:00
</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;