| New file |
| | |
| | | import React, { useState, useEffect, useCallback, useRef } from 'react'; |
| | | import { useLocation, useNavigate } from 'react-router-dom'; |
| | | import { useTranslate, usePermissions } from 'react-admin'; |
| | | import { Box, Tab, Tabs, IconButton, Tooltip, Divider, Menu, MenuItem } from '@mui/material'; |
| | | import CloseIcon from '@mui/icons-material/Close'; |
| | | import HomeIcon from '@mui/icons-material/Home'; |
| | | import SettingsIcon from '@mui/icons-material/Settings'; |
| | | import ClearAllIcon from '@mui/icons-material/ClearAll'; |
| | | |
| | | // 固定的标签页配置 (不可关闭) |
| | | const FIXED_TABS = [ |
| | | { path: '/dashboard', name: 'menu.dashboard', closable: false } |
| | | ]; |
| | | |
| | | // 特殊页面配置 |
| | | const SPECIAL_PAGES = { |
| | | '/settings': { name: 'menu.settings', closable: true }, |
| | | '/dashboard': { name: 'menu.dashboard', closable: false } |
| | | }; |
| | | |
| | | // 忽略的路径模式 (登录页、根路径等) |
| | | const IGNORED_PATHS = ['/', '/login']; |
| | | |
| | | // 从localStorage获取已保存的标签页 |
| | | const getSavedTabs = () => { |
| | | try { |
| | | const saved = localStorage.getItem('openTabs'); |
| | | if (saved) { |
| | | const parsed = JSON.parse(saved); |
| | | // 确保 dashboard 始终存在且是第一个 |
| | | const hasDashboard = parsed.some(tab => tab.path === '/dashboard'); |
| | | if (!hasDashboard) { |
| | | return [...FIXED_TABS, ...parsed.filter(tab => tab.path !== '/dashboard')]; |
| | | } |
| | | // 确保 dashboard 在第一位 |
| | | const dashboard = parsed.find(tab => tab.path === '/dashboard'); |
| | | const others = parsed.filter(tab => tab.path !== '/dashboard'); |
| | | return [{ ...dashboard, closable: false }, ...others]; |
| | | } |
| | | return [...FIXED_TABS]; |
| | | } catch { |
| | | return [...FIXED_TABS]; |
| | | } |
| | | }; |
| | | |
| | | // 保存标签页到localStorage |
| | | const saveTabs = (tabs) => { |
| | | try { |
| | | localStorage.setItem('openTabs', JSON.stringify(tabs)); |
| | | } catch (e) { |
| | | console.error('Failed to save tabs to localStorage:', e); |
| | | } |
| | | }; |
| | | |
| | | // 规范化路径 - 处理带ID的详情/编辑页面 |
| | | const normalizePath = (path) => { |
| | | if (!path) return path; |
| | | |
| | | // 匹配模式: /resource/123/show 或 /resource/123/edit 或 /resource/123 |
| | | // 将其规范化为基础路径 /resource |
| | | const patterns = [ |
| | | /^(\/[^/]+)\/\d+\/(show|edit)$/, // /task/123/show -> /task |
| | | /^(\/[^/]+)\/\d+$/, // /task/123 -> /task |
| | | /^(\/[^/]+)\/create$/, // /task/create -> /task |
| | | ]; |
| | | |
| | | for (const pattern of patterns) { |
| | | const match = path.match(pattern); |
| | | if (match) { |
| | | return match[1]; |
| | | } |
| | | } |
| | | |
| | | return path; |
| | | }; |
| | | |
| | | // 判断两个路径是否属于同一资源 |
| | | const isSameResource = (path1, path2) => { |
| | | return normalizePath(path1) === normalizePath(path2); |
| | | }; |
| | | |
| | | const TabsBar = () => { |
| | | const location = useLocation(); |
| | | const navigate = useNavigate(); |
| | | const translate = useTranslate(); |
| | | const { permissions } = usePermissions(); |
| | | const [tabs, setTabs] = useState(getSavedTabs); |
| | | const pendingNavigationRef = useRef(null); |
| | | const [contextMenu, setContextMenu] = useState(null); |
| | | const [contextMenuTab, setContextMenuTab] = useState(null); |
| | | |
| | | // 翻译辅助函数,如果翻译不存在则返回默认值 |
| | | const t = useCallback((key, defaultValue) => { |
| | | const translated = translate(key); |
| | | return translated === key ? defaultValue : translated; |
| | | }, [translate]); |
| | | |
| | | // 处理待执行的导航 |
| | | useEffect(() => { |
| | | if (pendingNavigationRef.current) { |
| | | navigate(pendingNavigationRef.current); |
| | | pendingNavigationRef.current = null; |
| | | } |
| | | }); |
| | | |
| | | // 根据路径查找菜单名称 |
| | | const findMenuName = useCallback((path, menus) => { |
| | | if (!menus || !path) return null; |
| | | const cleanPath = path.replace(/^\//, ''); |
| | | const normalizedCleanPath = normalizePath('/' + cleanPath).replace(/^\//, ''); |
| | | |
| | | const searchMenu = (items) => { |
| | | for (const item of items) { |
| | | if (item.component === cleanPath || item.component === normalizedCleanPath) { |
| | | return item.name; |
| | | } |
| | | if (item.children) { |
| | | const found = searchMenu(item.children); |
| | | if (found) return found; |
| | | } |
| | | } |
| | | return null; |
| | | }; |
| | | return searchMenu(menus); |
| | | }, []); |
| | | |
| | | // 获取标签页显示名称 |
| | | const getTabLabel = useCallback((tab) => { |
| | | // 固定标签页直接返回翻译后的名称 |
| | | if (tab.name) { |
| | | return translate(tab.name); |
| | | } |
| | | |
| | | // 检查特殊页面 |
| | | const normalizedPath = normalizePath(tab.path); |
| | | if (SPECIAL_PAGES[normalizedPath]) { |
| | | return translate(SPECIAL_PAGES[normalizedPath].name); |
| | | } |
| | | |
| | | // 动态标签页尝试从权限中获取名称 |
| | | if (permissions) { |
| | | const menuName = findMenuName(tab.path, permissions); |
| | | if (menuName) { |
| | | return translate(menuName); |
| | | } |
| | | } |
| | | // 默认返回路径 |
| | | return tab.path.replace(/^\//, '').split('/')[0] || tab.path; |
| | | }, [translate, permissions, findMenuName]); |
| | | |
| | | // 监听路由变化,添加新标签页 |
| | | useEffect(() => { |
| | | const currentPath = location.pathname; |
| | | |
| | | // 忽略特定路径 |
| | | if (IGNORED_PATHS.includes(currentPath)) { |
| | | return; |
| | | } |
| | | |
| | | setTabs(prevTabs => { |
| | | // 检查是否已有相同资源的标签页 |
| | | const existingTabIndex = prevTabs.findIndex(tab => |
| | | tab.path === currentPath || isSameResource(tab.path, currentPath) |
| | | ); |
| | | |
| | | if (existingTabIndex >= 0) { |
| | | // 如果路径完全相同,不需要更新 |
| | | if (prevTabs[existingTabIndex].path === currentPath) { |
| | | return prevTabs; |
| | | } |
| | | // 更新现有标签页的路径(保持标签位置) |
| | | const updatedTabs = [...prevTabs]; |
| | | updatedTabs[existingTabIndex] = { |
| | | ...updatedTabs[existingTabIndex], |
| | | path: currentPath |
| | | }; |
| | | saveTabs(updatedTabs); |
| | | return updatedTabs; |
| | | } |
| | | |
| | | // 查找菜单名称 |
| | | let menuName = null; |
| | | if (permissions) { |
| | | menuName = findMenuName(currentPath, permissions); |
| | | } |
| | | |
| | | // 检查特殊页面 |
| | | const normalizedPath = normalizePath(currentPath); |
| | | if (SPECIAL_PAGES[normalizedPath] && !menuName) { |
| | | menuName = SPECIAL_PAGES[normalizedPath].name; |
| | | } |
| | | |
| | | // 添加新标签页 |
| | | const newTab = { |
| | | path: currentPath, |
| | | name: menuName, |
| | | closable: SPECIAL_PAGES[normalizedPath]?.closable !== false |
| | | }; |
| | | const newTabs = [...prevTabs, newTab]; |
| | | saveTabs(newTabs); |
| | | return newTabs; |
| | | }); |
| | | }, [location.pathname, permissions, findMenuName]); |
| | | |
| | | // 切换标签页 |
| | | const handleTabChange = (event, newValue) => { |
| | | const targetTab = tabs[newValue]; |
| | | if (targetTab && targetTab.path !== location.pathname) { |
| | | navigate(targetTab.path); |
| | | } |
| | | }; |
| | | |
| | | // 关闭标签页 |
| | | const handleCloseTab = (event, tabPath) => { |
| | | event.stopPropagation(); |
| | | event.preventDefault(); |
| | | |
| | | const tabIndex = tabs.findIndex(tab => tab.path === tabPath); |
| | | const newTabs = tabs.filter(tab => tab.path !== tabPath); |
| | | |
| | | // 如果关闭的是当前标签页,需要导航到其他标签页 |
| | | if (location.pathname === tabPath || isSameResource(location.pathname, tabPath)) { |
| | | // 优先导航到左边的标签页,否则导航到右边的 |
| | | const newIndex = Math.min(tabIndex, newTabs.length - 1); |
| | | if (newIndex >= 0 && newTabs[newIndex]) { |
| | | pendingNavigationRef.current = newTabs[newIndex].path; |
| | | } else { |
| | | pendingNavigationRef.current = '/dashboard'; |
| | | } |
| | | } |
| | | |
| | | saveTabs(newTabs); |
| | | setTabs(newTabs); |
| | | }; |
| | | |
| | | |
| | | |
| | | // 关闭所有标签页(除了dashboard) |
| | | const handleCloseAll = () => { |
| | | const dashboardTab = tabs.find(tab => tab.path === '/dashboard'); |
| | | const newTabs = [dashboardTab || { ...FIXED_TABS[0] }]; |
| | | |
| | | saveTabs(newTabs); |
| | | setTabs(newTabs); |
| | | |
| | | if (location.pathname !== '/dashboard') { |
| | | navigate('/dashboard'); |
| | | } |
| | | }; |
| | | |
| | | // 关闭其他标签页(保留指定标签页) |
| | | const handleCloseOthers = (keepTabPath) => { |
| | | const keepTab = tabs.find(tab => tab.path === keepTabPath); |
| | | if (!keepTab) { |
| | | return; |
| | | } |
| | | |
| | | // 保留指定标签页和dashboard(如果dashboard不是指定标签页) |
| | | const dashboardTab = tabs.find(tab => tab.path === '/dashboard'); |
| | | const newTabs = []; |
| | | |
| | | // 如果指定标签页不是dashboard,确保dashboard也在列表中 |
| | | if (keepTab.path !== '/dashboard' && dashboardTab) { |
| | | newTabs.push(dashboardTab); |
| | | } |
| | | |
| | | // 添加指定标签页(如果还没有添加) |
| | | if (!newTabs.some(tab => tab.path === keepTab.path)) { |
| | | newTabs.push(keepTab); |
| | | } |
| | | |
| | | saveTabs(newTabs); |
| | | setTabs(newTabs); |
| | | setContextMenu(null); |
| | | setContextMenuTab(null); |
| | | }; |
| | | |
| | | // 关闭左侧标签页(保留指定标签页及其右侧的所有标签页) |
| | | // const handleCloseLeft = (keepTabPath) => { |
| | | // const keepTabIndex = tabs.findIndex(tab => tab.path === keepTabPath); |
| | | // if (keepTabIndex < 0) { |
| | | // return; |
| | | // } |
| | | |
| | | // // 保留指定标签页及其右侧的所有标签页 |
| | | // const newTabs = tabs.slice(keepTabIndex); |
| | | |
| | | // // 确保dashboard始终在第一位(如果存在) |
| | | // const dashboardTab = newTabs.find(tab => tab.path === '/dashboard'); |
| | | // if (dashboardTab && newTabs[0].path !== '/dashboard') { |
| | | // const dashboardIndex = newTabs.findIndex(tab => tab.path === '/dashboard'); |
| | | // if (dashboardIndex > 0) { |
| | | // newTabs.splice(dashboardIndex, 1); |
| | | // newTabs.unshift(dashboardTab); |
| | | // } |
| | | // } |
| | | |
| | | // saveTabs(newTabs); |
| | | // setTabs(newTabs); |
| | | // setContextMenu(null); |
| | | // setContextMenuTab(null); |
| | | // }; |
| | | |
| | | // 关闭右侧标签页(保留指定标签页及其左侧的所有标签页) |
| | | // const handleCloseRight = (keepTabPath) => { |
| | | // const keepTabIndex = tabs.findIndex(tab => tab.path === keepTabPath); |
| | | // if (keepTabIndex < 0) { |
| | | // return; |
| | | // } |
| | | |
| | | // // 保留指定标签页及其左侧的所有标签页 |
| | | // const newTabs = tabs.slice(0, keepTabIndex + 1); |
| | | |
| | | // saveTabs(newTabs); |
| | | // setTabs(newTabs); |
| | | // setContextMenu(null); |
| | | // setContextMenuTab(null); |
| | | // }; |
| | | |
| | | // 处理右键菜单 |
| | | const handleContextMenu = (event, tab) => { |
| | | event.preventDefault(); |
| | | event.stopPropagation(); |
| | | setContextMenu( |
| | | contextMenu === null |
| | | ? { |
| | | mouseX: event.clientX + 2, |
| | | mouseY: event.clientY - 6, |
| | | } |
| | | : null |
| | | ); |
| | | setContextMenuTab(tab); |
| | | }; |
| | | |
| | | // 关闭右键菜单 |
| | | const handleCloseContextMenu = () => { |
| | | setContextMenu(null); |
| | | setContextMenuTab(null); |
| | | }; |
| | | |
| | | // 右键菜单:关闭当前标签 |
| | | const handleCloseCurrentTab = () => { |
| | | if (!contextMenuTab) { |
| | | handleCloseContextMenu(); |
| | | return; |
| | | } |
| | | |
| | | const tabPath = contextMenuTab.path; |
| | | const tabIndex = tabs.findIndex(tab => tab.path === tabPath); |
| | | const newTabs = tabs.filter(tab => tab.path !== tabPath); |
| | | |
| | | // 如果关闭的是当前标签页,需要导航到其他标签页 |
| | | if (location.pathname === tabPath || isSameResource(location.pathname, tabPath)) { |
| | | // 优先导航到左边的标签页,否则导航到右边的 |
| | | const newIndex = Math.min(tabIndex, newTabs.length - 1); |
| | | if (newIndex >= 0 && newTabs[newIndex]) { |
| | | pendingNavigationRef.current = newTabs[newIndex].path; |
| | | } else { |
| | | pendingNavigationRef.current = '/dashboard'; |
| | | } |
| | | } |
| | | |
| | | saveTabs(newTabs); |
| | | setTabs(newTabs); |
| | | handleCloseContextMenu(); |
| | | }; |
| | | |
| | | // 右键菜单:关闭其他标签 |
| | | const handleCloseOtherTabs = () => { |
| | | if (contextMenuTab) { |
| | | handleCloseOthers(contextMenuTab.path); |
| | | } |
| | | }; |
| | | |
| | | // 右键菜单:关闭左侧标签 |
| | | // const handleCloseLeftTabs = () => { |
| | | // if (contextMenuTab) { |
| | | // handleCloseLeft(contextMenuTab.path); |
| | | // } |
| | | // }; |
| | | |
| | | // 右键菜单:关闭右侧标签 |
| | | // const handleCloseRightTabs = () => { |
| | | // if (contextMenuTab) { |
| | | // handleCloseRight(contextMenuTab.path); |
| | | // } |
| | | // }; |
| | | |
| | | |
| | | // 获取当前标签页索引 |
| | | const currentTabIndex = tabs.findIndex(tab => |
| | | tab.path === location.pathname || isSameResource(tab.path, location.pathname) |
| | | ); |
| | | const activeIndex = currentTabIndex >= 0 ? currentTabIndex : 0; |
| | | |
| | | // 判断是否有可关闭的标签 |
| | | const hasClosableTabs = tabs.some(tab => tab.closable); |
| | | |
| | | // 判断指定标签页是否可以关闭其他标签 |
| | | const canCloseOthersForTab = (tabPath) => { |
| | | return tabs.filter(tab => { |
| | | const isTargetTab = tab.path === tabPath || isSameResource(tab.path, tabPath); |
| | | return !isTargetTab && tab.closable; |
| | | }).length > 0; |
| | | }; |
| | | |
| | | // 判断指定标签页是否可以关闭左侧标签 |
| | | // const canCloseLeftForTab = (tabPath) => { |
| | | // const tabIndex = tabs.findIndex(tab => tab.path === tabPath); |
| | | // if (tabIndex <= 0) { |
| | | // return false; // 没有左侧标签或这是第一个标签 |
| | | // } |
| | | // // 检查左侧是否有可关闭的标签(排除dashboard) |
| | | // return tabs.slice(0, tabIndex).some(tab => tab.closable); |
| | | // }; |
| | | |
| | | // 判断指定标签页是否可以关闭右侧标签 |
| | | // const canCloseRightForTab = (tabPath) => { |
| | | // const tabIndex = tabs.findIndex(tab => tab.path === tabPath); |
| | | // if (tabIndex < 0 || tabIndex >= tabs.length - 1) { |
| | | // return false; // 没有右侧标签或这是最后一个标签 |
| | | // } |
| | | // // 检查右侧是否有可关闭的标签 |
| | | // return tabs.slice(tabIndex + 1).some(tab => tab.closable); |
| | | // }; |
| | | |
| | | return ( |
| | | <Box |
| | | sx={{ |
| | | width: '100%', |
| | | backgroundColor: '#fff', |
| | | borderBottom: '1px solid #e0e0e0', |
| | | boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | }} |
| | | > |
| | | <Tabs |
| | | value={activeIndex} |
| | | onChange={handleTabChange} |
| | | variant="scrollable" |
| | | scrollButtons="auto" |
| | | sx={{ |
| | | flex: 1, |
| | | minHeight: 36, |
| | | '& .MuiTabs-indicator': { |
| | | backgroundColor: '#1976d2', |
| | | height: 2, |
| | | }, |
| | | '& .MuiTabs-scrollButtons': { |
| | | width: 28, |
| | | }, |
| | | }} |
| | | > |
| | | {tabs.map((tab, index) => ( |
| | | <Tab |
| | | key={tab.path} |
| | | label={ |
| | | <Box |
| | | onContextMenu={(e) => handleContextMenu(e, tab)} |
| | | sx={{ |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 0.5, |
| | | width: '100%', |
| | | }} |
| | | > |
| | | {tab.path === '/dashboard' && ( |
| | | <HomeIcon sx={{ fontSize: 16 }} /> |
| | | )} |
| | | {normalizePath(tab.path) === '/settings' && ( |
| | | <SettingsIcon sx={{ fontSize: 16 }} /> |
| | | )} |
| | | <span>{getTabLabel(tab)}</span> |
| | | {tab.closable && ( |
| | | <Tooltip title="关闭"> |
| | | <Box |
| | | component="span" |
| | | onClick={(e) => handleCloseTab(e, tab.path)} |
| | | sx={{ |
| | | display: 'inline-flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | | p: 0.25, |
| | | ml: 0.5, |
| | | borderRadius: '50%', |
| | | cursor: 'pointer', |
| | | '&:hover': { |
| | | backgroundColor: 'rgba(0, 0, 0, 0.1)', |
| | | }, |
| | | }} |
| | | > |
| | | <CloseIcon sx={{ fontSize: 14 }} /> |
| | | </Box> |
| | | </Tooltip> |
| | | )} |
| | | </Box> |
| | | } |
| | | sx={{ |
| | | minHeight: 36, |
| | | py: 0.5, |
| | | px: 1.5, |
| | | textTransform: 'none', |
| | | fontSize: '0.8125rem', |
| | | fontWeight: activeIndex === index ? 600 : 400, |
| | | color: activeIndex === index ? '#1976d2' : 'text.secondary', |
| | | backgroundColor: activeIndex === index ? 'rgba(25, 118, 210, 0.08)' : 'transparent', |
| | | borderRadius: '4px 4px 0 0', |
| | | minWidth: 'auto', |
| | | '&:hover': { |
| | | backgroundColor: 'rgba(25, 118, 210, 0.12)', |
| | | }, |
| | | '&.Mui-selected': { |
| | | color: '#1976d2', |
| | | }, |
| | | }} |
| | | /> |
| | | ))} |
| | | </Tabs> |
| | | {/* 清除所有标签按钮 */} |
| | | {hasClosableTabs && ( |
| | | <Box sx={{ display: 'flex', alignItems: 'center', pr: 1 }}> |
| | | <Divider orientation="vertical" flexItem sx={{ mx: 0.5, height: 20, alignSelf: 'center' }} /> |
| | | <Tooltip title="关闭所有标签"> |
| | | <IconButton |
| | | size="small" |
| | | onClick={handleCloseAll} |
| | | sx={{ |
| | | p: 0.5, |
| | | color: 'text.secondary', |
| | | '&:hover': { |
| | | backgroundColor: 'rgba(0, 0, 0, 0.08)', |
| | | color: '#d32f2f', |
| | | }, |
| | | }} |
| | | > |
| | | <ClearAllIcon sx={{ fontSize: 18 }} /> |
| | | </IconButton> |
| | | </Tooltip> |
| | | </Box> |
| | | )} |
| | | {/* 右键菜单 */} |
| | | <Menu |
| | | open={contextMenu !== null} |
| | | onClose={handleCloseContextMenu} |
| | | anchorReference="anchorPosition" |
| | | anchorPosition={ |
| | | contextMenu !== null |
| | | ? { top: contextMenu.mouseY, left: contextMenu.mouseX } |
| | | : undefined |
| | | } |
| | | > |
| | | {contextMenuTab && contextMenuTab.closable && ( |
| | | <MenuItem onClick={handleCloseCurrentTab}> |
| | | {t('ra.action.close', '关闭当前标签')} |
| | | </MenuItem> |
| | | )} |
| | | {/* {contextMenuTab && canCloseLeftForTab(contextMenuTab.path) && ( |
| | | <MenuItem onClick={handleCloseLeftTabs}> |
| | | {t('ra.action.closeLeft', '关闭左侧标签')} |
| | | </MenuItem> |
| | | )} |
| | | {contextMenuTab && canCloseRightForTab(contextMenuTab.path) && ( |
| | | <MenuItem onClick={handleCloseRightTabs}> |
| | | {t('ra.action.closeRight', '关闭右侧标签')} |
| | | </MenuItem> |
| | | )} */} |
| | | {contextMenuTab && canCloseOthersForTab(contextMenuTab.path) && ( |
| | | <MenuItem onClick={handleCloseOtherTabs}> |
| | | {t('ra.action.closeOthers', '关闭其他标签')} |
| | | </MenuItem> |
| | | )} |
| | | </Menu> |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | export default TabsBar; |