import React, { useEffect, useMemo, useRef, useState } from 'react' import { Layout, Menu, theme, Avatar, Dropdown, Space, Modal, Form, Input, message, Breadcrumb, ConfigProvider, Tabs } from 'antd' import { useNavigate, useLocation, useOutlet } from 'react-router-dom' import { HomeOutlined, LogoutOutlined, TeamOutlined, SettingOutlined, AppstoreOutlined, KeyOutlined, UserOutlined, DashboardOutlined, FileOutlined, LockOutlined, MenuOutlined, PieChartOutlined, BarChartOutlined, TableOutlined, CalendarOutlined, FormOutlined, SearchOutlined, ToolOutlined, ShoppingCartOutlined, ShopOutlined, FolderOpenOutlined, FolderOutlined, CloudOutlined, DatabaseOutlined, ApiOutlined, CodeOutlined, BugOutlined, BellOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, EyeOutlined, EyeInvisibleOutlined, StarOutlined, HeartOutlined, LikeOutlined, DislikeOutlined, SmileOutlined, FrownOutlined, PhoneOutlined, MailOutlined, EnvironmentOutlined, GlobalOutlined, AimOutlined, CompassOutlined, CameraOutlined, VideoCameraOutlined, SoundOutlined, WifiOutlined, RocketOutlined, ThunderboltOutlined, ExperimentOutlined, BulbOutlined, GiftOutlined, BankOutlined, WalletOutlined, MoneyCollectOutlined, BookOutlined, ReadOutlined, ProfileOutlined, CloudUploadOutlined, CloudDownloadOutlined, InboxOutlined, FolderAddOutlined, SlidersOutlined, FilterOutlined, AlertOutlined, ClockCircleOutlined, FieldTimeOutlined, HistoryOutlined, ContactsOutlined, SolutionOutlined, IdcardOutlined, QrcodeOutlined, ScanOutlined, SafetyOutlined, SecurityScanOutlined, UnlockOutlined, HddOutlined, CopyOutlined, ScissorOutlined, SnippetsOutlined, FileProtectOutlined, DesktopOutlined, LaptopOutlined, MobileOutlined, TabletOutlined, ClusterOutlined, AppstoreAddOutlined, PlusSquareOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons' import { clearToken, getUser as getUserLocal, setUser as setUserLocal } from '../utils/token' import api from '../utils/axios' import './layout.css' import { PermissionProvider } from '../utils/permission' import { APP_CONFIG } from '../utils/config' const { Header, Sider, Content } = Layout interface MenuItemResp { id: number; parent_id?: number | null; name: string; path?: string | null; component?: string | null; type: number; icon?: string | null; order_no: number; visible: boolean; status: number; perms?: string | null; keep_alive?: boolean } import zhCN from 'antd/locale/zh_CN' import enUS from 'antd/locale/en_US' export default function MainLayout() { const navigate = useNavigate() const loc = useLocation() const { token: { colorBgContainer, borderRadiusLG }, } = theme.useToken() // 简易语言切换(存储到 localStorage) const [lang, setLang] = useState<'zh' | 'en'>(() => (localStorage.getItem('lang') === 'en' ? 'en' : 'zh')) const localeObj = lang === 'zh' ? zhCN : enUS const [user, setUser] = useState(() => getUserLocal()) const [profileOpen, setProfileOpen] = useState(false) const [form] = Form.useForm() const [rawMenus, setRawMenus] = useState([] as MenuItemResp[]) const [menuItems, setMenuItems] = useState([] as any[]) // 从菜单提取权限编码集合(后端通过菜单返回 perms,包含页面/按钮权限) const permissionCodes = useMemo(() => { const set = new Set() rawMenus.forEach((m: MenuItemResp) => { const p = m.perms if (p && typeof p === 'string') { p.split(',').map(s => s.trim()).filter(Boolean).forEach(code => set.add(code)) } }) return set }, [rawMenus]) const iconMap: Record = useMemo(() => ({ HomeOutlined: , UserOutlined: , TeamOutlined: , SettingOutlined: , AppstoreOutlined: , KeyOutlined: , DashboardOutlined: , FileOutlined: , LockOutlined: , MenuOutlined: , PieChartOutlined: , BarChartOutlined: , TableOutlined: , CalendarOutlined: , FormOutlined: , SearchOutlined: , ToolOutlined: , ShoppingCartOutlined: , ShopOutlined: , FolderOpenOutlined: , FolderOutlined: , CloudOutlined: , DatabaseOutlined: , ApiOutlined: , CodeOutlined: , BugOutlined: , BellOutlined: , // 新增常用图标映射 PlusOutlined: , EditOutlined: , DeleteOutlined: , UploadOutlined: , DownloadOutlined: , EyeOutlined: , EyeInvisibleOutlined: , StarOutlined: , HeartOutlined: , LikeOutlined: , DislikeOutlined: , SmileOutlined: , FrownOutlined: , PhoneOutlined: , MailOutlined: , EnvironmentOutlined: , GlobalOutlined: , AimOutlined: , CompassOutlined: , CameraOutlined: , VideoCameraOutlined: , SoundOutlined: , WifiOutlined: , RocketOutlined: , ThunderboltOutlined: , ExperimentOutlined: , BulbOutlined: , GiftOutlined: , BankOutlined: , WalletOutlined: , MoneyCollectOutlined: , BookOutlined: , ReadOutlined: , ProfileOutlined: , CloudUploadOutlined: , CloudDownloadOutlined: , InboxOutlined: , FolderAddOutlined: , SlidersOutlined: , FilterOutlined: , AlertOutlined: , ClockCircleOutlined: , FieldTimeOutlined: , HistoryOutlined: , ContactsOutlined: , SolutionOutlined: , IdcardOutlined: , QrcodeOutlined: , ScanOutlined: , SafetyOutlined: , SecurityScanOutlined: , UnlockOutlined: , HddOutlined: , CopyOutlined: , ScissorOutlined: , SnippetsOutlined: , FileProtectOutlined: , DesktopOutlined: , LaptopOutlined: , MobileOutlined: , TabletOutlined: , ClusterOutlined: , AppstoreAddOutlined: , PlusSquareOutlined: , SyncOutlined: , ReloadOutlined: , }), []) useEffect(() => { const fetchMenus = async () => { try { const { data } = await api.get('/auth/menus') if (data?.code === 0) { const list: MenuItemResp[] = data.data || [] setRawMenus(list) } } catch (e) { // ignore } } fetchMenus() }, []) useEffect(() => { // 前端移除不再需要的“权限”相关菜单项 const filtered = rawMenus.filter((m: MenuItemResp) => m.path !== '/permissions' && m.path !== '/demo/perms') // build tree items from filtered const map = new Map() const byId = new Map() filtered.forEach((m: MenuItemResp) => byId.set(m.id, m)) filtered .filter((m: MenuItemResp) => m.type !== 3) // skip buttons .forEach((m: MenuItemResp) => { const key = m.path && m.path.startsWith('/') ? m.path : `m-${m.id}` const item: any = { key, label: m.name, icon: m.icon ? iconMap[m.icon] : undefined, // NOTE: 不要预先放 children: [],否则在 antd Menu 中会被当成可展开项 } map.set(m.id, item) }) const roots: any[] = [] filtered .filter((m: MenuItemResp) => m.type !== 3) .forEach((m: MenuItemResp) => { const node = map.get(m.id) const pid = m.parent_id || undefined // 只有当父级不是按钮时,才允许挂载,避免将节点放到按钮下 if (pid && map.has(pid) && byId.get(pid)?.type !== 3) { const parent = map.get(pid) if (!parent.children) parent.children = [] parent.children.push(node) } else { roots.push(node) } }) setMenuItems(roots) }, [rawMenus, iconMap]) const handleLogout = async () => { try { await api.post('/auth/logout') } catch (_) {} clearToken() navigate('/login', { replace: true }) } const openProfile = () => { const u = getUserLocal() setUser(u) form.setFieldsValue({ username: u?.username, nickname: u?.nickname, avatar: (u as any)?.avatar || '' }) setProfileOpen(true) } const handleSaveProfile = async () => { try{ const values = await form.validateFields() const uid = (user as any)?.id if (uid) { const { data } = await api.put(`/users/${uid}`, { nickname: values.nickname }) if (data?.code !== 0) throw new Error(data?.message || '更新失败') } const merged = { ...(user as any), nickname: values.nickname, avatar: values.avatar } setUserLocal(merged) setUser(merged) message.success('资料已更新') setProfileOpen(false) }catch(e: any){ if(e?.errorFields) return; message.error(e?.message || '更新失败') } } const onMenuClick = (info: any) => { const key = info?.key as string if (key && key.startsWith('/')) navigate(key) } const selectedKeys = useMemo(() => { const k = loc.pathname return [k] }, [loc.pathname]) // 根据当前路径计算需要展开的父级菜单 keys(不包含叶子自身) const [openKeys, setOpenKeys] = useState(() => { try { const v = localStorage.getItem('layout:menuOpenKeys') const arr = v ? JSON.parse(v) : [] return Array.isArray(arr) ? (arr as string[]) : [] } catch { return [] } }) // 持久化用户手动展开/收起的子菜单状态,刷新后保持 useEffect(() => { try { localStorage.setItem('layout:menuOpenKeys', JSON.stringify(openKeys)) } catch {} }, [openKeys]) // 确保当前路由对应的父级菜单也处于展开状态,但不覆盖用户手动展开的其他菜单(合并而非替换) const currentAncestors = useMemo(() => { const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms') const idMap = new Map() const pathToId = new Map() list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) }) const currentPath = loc.pathname let foundId: number | undefined = pathToId.get(currentPath) if (!foundId) { const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path)) if (candidates.length) { candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0)) foundId = candidates[0].id } } if (!foundId) return [] as string[] const chain: MenuItemResp[] = [] let cur: MenuItemResp | undefined = idMap.get(foundId) let guard = 0 while (cur && guard++ < 50) { chain.push(cur) const pid = cur.parent_id || undefined cur = pid ? idMap.get(pid) : undefined } chain.reverse() const ancestors = chain.slice(0, Math.max(0, chain.length - 1)) return ancestors.map((m: MenuItemResp) => (m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`)) }, [rawMenus, loc.pathname]) useEffect(() => { setOpenKeys(prev => { const s = new Set(prev) currentAncestors.forEach(k => s.add(k)) return Array.from(s) }) }, [currentAncestors]) const dropdownMenuItems = useMemo(() => ([ { key: 'profile', label: '个人信息', icon: }, { type: 'divider' as any }, { key: 'logout', label: '退出登录', icon: }, ]), []) // 基于菜单树与当前路径计算面包屑 const breadcrumbItems = useMemo(() => { // 过滤掉不需要显示的菜单与按钮(type 3) const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms') const idMap = new Map() const pathToId = new Map() list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) }) const currentPath = loc.pathname let foundId: number | undefined if (pathToId.has(currentPath)) { foundId = pathToId.get(currentPath)! } else { // 兜底:取路径前缀最长匹配(例如 /users/detail/123 匹配 /users) const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path)) if (candidates.length) { candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0)) foundId = candidates[0].id } } if (!foundId) return [] as any[] const chain: MenuItemResp[] = [] let cur: MenuItemResp | undefined = idMap.get(foundId) let guard = 0 while (cur && guard++ < 50) { chain.push(cur) const pid = cur.parent_id || undefined cur = pid ? idMap.get(pid) : undefined } chain.reverse() return chain.map((m: MenuItemResp, idx: number) => ({ title: (m.path && idx !== chain.length - 1) ? { e.preventDefault(); navigate(m.path!) }}>{m.name} : m.name })) }, [rawMenus, loc.pathname, navigate]) // 路由页签与缓存(放在 return 之前的顶层) type TabItem = { key: string; title: string; keepAlive: boolean } const [tabs, setTabs] = useState([]) const [cache, setCache] = useState>({}) const outlet = useOutlet() // 右侧菜单折叠状态(默认收起) // 左侧菜单折叠状态:持久化到 localStorage,刷新不丢失 const [leftCollapsed, setLeftCollapsed] = useState(() => { try { const v = localStorage.getItem('layout:leftCollapsed') return v ? v === '1' : false } catch { return false } }) // 断点状态:仅用于小屏时临时强制折叠,不写入持久化 const [isBroken, setIsBroken] = useState(false) useEffect(() => { try { localStorage.setItem('layout:leftCollapsed', leftCollapsed ? '1' : '0') } catch {} }, [leftCollapsed]) // 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态) // 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态) // path -> menu 映射,便于取标题与 keep_alive const pathToMenu = useMemo(() => { const map = new Map() rawMenus.forEach((m: MenuItemResp) => { if (m.path) map.set(m.path, m) }) return map }, [rawMenus]) // 当前路由激活 key const activeKey = useMemo(() => loc.pathname, [loc.pathname]) // 根据菜单变更,修正已存在 tabs 的标题与 keepAlive(解决刷新后标题变为路径的问题) useEffect(() => { setTabs(prev => { let changed = false const next = prev.map(t => { // 特殊处理首页:无菜单映射时也应显示“首页” if (t.key === '/') { const nextTitle = '首页' const nextKeep = true if (t.title !== nextTitle || t.keepAlive !== nextKeep) { changed = true return { ...t, title: nextTitle, keepAlive: nextKeep } } return t } const m = pathToMenu.get(t.key) if (!m) return t const nextKeep = (typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true const nextTitle = m.name || t.title if (t.title !== nextTitle || t.keepAlive !== nextKeep) { changed = true return { ...t, title: nextTitle, keepAlive: nextKeep } } return t }) return changed ? next : prev }) }, [pathToMenu]) // 根据当前路由补齐 tabs useEffect(() => { const curPath = loc.pathname if (!curPath.startsWith('/')) return const m = pathToMenu.get(curPath) const title = m?.name || (curPath === '/' ? '首页' : curPath) const keepAlive = (m && typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true setTabs(prev => (prev.some(t => t.key === curPath) ? prev : [...prev, { key: curPath, title, keepAlive }])) }, [loc.pathname, pathToMenu]) // 确保缓存当前激活页(若 keepAlive 打开) useEffect(() => { const cur = tabs.find(t => t.key === activeKey) if (!cur) return if (cur.keepAlive && outlet && !cache[activeKey]) { setCache(prev => ({ ...prev, [activeKey]: outlet })) } }, [activeKey, outlet, tabs, cache]) const onTabChange = (key: string) => { if (key && key !== activeKey) navigate(key) } const onTabEdit = (targetKey: any, action: 'add' | 'remove') => { if (action === 'remove') { const tKey = String(targetKey) if (tKey === '/') return setTabs(prev => { const idx = prev.findIndex(t => t.key === tKey) if (idx === -1) return prev const next = prev.filter(t => t.key !== tKey) // 清理缓存 setCache(c => { const n = { ...c }; delete n[tKey]; return n }) // 如果关闭的是当前激活页,跳转到相邻页 if (tKey === activeKey) { const fallback = next[idx - 1] || next[idx] || { key: '/' } if (fallback.key) navigate(fallback.key) } return next }) } } // 确保“首页”始终存在且固定(仅初始化时补齐一次) useEffect(() => { setTabs(prev => (prev.some(t => t.key === '/') ? prev : [{ key: '/', title: '首页', keepAlive: true }, ...prev])) }, []) return ( {/* 其余内容保持不变 */} { // 仅在用户点击触发器时更新用户偏好;响应式折叠不写入 if (type === 'clickTrigger') { setLeftCollapsed(collapsed) } }} breakpoint="lg" onBreakpoint={(broken) => setIsBroken(broken)} trigger={null} collapsedWidth={56} width={220} style={{ background: colorBgContainer }} >
setLeftCollapsed(c => !c)} title={leftCollapsed ? '展开菜单' : '收起菜单'} > {leftCollapsed ? APP_CONFIG.SITE_NAME_SHORT : APP_CONFIG.SITE_NAME}
setOpenKeys(keys as string[])} inlineCollapsed={isBroken ? true : leftCollapsed} items={menuItems} onClick={onMenuClick} style={{ borderInlineEnd: 0 }} />
{/* 用户要求:隐藏展开/收起图标,保留名称点击收起/展开 */} {tabs.length > 0 && ( ({ key: t.key, label: t.title, closable: t.key !== '/' }))} activeKey={activeKey} onChange={onTabChange} onEdit={onTabEdit as any} /> )}
{ const v = key as 'zh'|'en'; setLang(v); localStorage.setItem('lang', v) } }} placement="bottomLeft" > {lang === 'zh' ? 'CN' : 'EN'} { if (e.key === 'logout') handleLogout(); if (e.key === 'profile') openProfile() } }} placement="bottomRight" > } /> {(user as any)?.nickname || (user as any)?.username || '用户'}
{/* 缓存的标签页:非激活隐藏 */} {tabs.filter(t => t.keepAlive).map(t => (
{cache[t.key]}
))} {/* 未开启缓存:只渲染当前路由 */} {(() => { const cur = tabs.find(t => t.key === activeKey) if (!cur) return null if (cur.keepAlive) return null return
{outlet}
})()}
setProfileOpen(false)} okText="保存" width={840}>
) }