zhou zhou
2026-01-08 01ea11a8e7a217b51208ae31ca7bd4c7cd13f7fa
#tabsBar
1个文件已添加
1个文件已修改
423 ■■■■■ 已修改文件
rsf-admin/src/layout/TabsBar.jsx 364 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/index.jsx 59 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/TabsBar.jsx
New file
@@ -0,0 +1,364 @@
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 } 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);
    // 处理待执行的导航
    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 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);
    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
                                sx={{
                                    display: 'flex',
                                    alignItems: 'center',
                                    gap: 0.5,
                                }}
                            >
                                {tab.path === '/dashboard' && (
                                    <HomeIcon sx={{ fontSize: 16 }} />
                                )}
                                {normalizePath(tab.path) === '/settings' && (
                                    <SettingsIcon sx={{ fontSize: 16 }} />
                                )}
                                <span>{getTabLabel(tab)}</span>
                                {tab.closable && (
                                    <Tooltip title="关闭">
                                        <IconButton
                                            size="small"
                                            onClick={(e) => handleCloseTab(e, tab.path)}
                                            sx={{
                                                p: 0.25,
                                                ml: 0.5,
                                                '&:hover': {
                                                    backgroundColor: 'rgba(0, 0, 0, 0.1)',
                                                },
                                            }}
                                        >
                                            <CloseIcon sx={{ fontSize: 14 }} />
                                        </IconButton>
                                    </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>
            )}
        </Box>
    );
};
export default TabsBar;
rsf-admin/src/layout/index.jsx
@@ -1,14 +1,53 @@
import { Layout as RALayout, CheckForApplicationUpdate } from "react-admin";
import { Layout as RALayout, CheckForApplicationUpdate, useSidebarState } from "react-admin";
import AppBar from './AppBar';
import { MyMenu } from './MyMenu'
import { MyMenu } from './MyMenu';
import TabsBar from './TabsBar';
import { Box } from '@mui/material';
const LayoutContent = ({ children }) => {
  const [sidebarIsOpen] = useSidebarState();
  const sidebarWidth = sidebarIsOpen ? 200 : 50;
  return (
    <RALayout
      appBar={AppBar}
      menu={MyMenu}
      sx={{
        '& .RaLayout-content': {
          position: 'absolute',
          left: `${sidebarWidth}px`,
          overflowY: 'auto',
          width: `calc(100% - ${sidebarWidth}px)`,
          height: 'calc(100% - 86px)', // 减去TabsBar的高度 (50px AppBar + 36px TabsBar)
          top: '86px',
          transition: (theme) =>
            theme.transitions.create(['left', 'width'], {
              easing: theme.transitions.easing.sharp,
              duration: theme.transitions.duration.leavingScreen,
            }),
        }
      }}
    >
      <Box sx={{
        position: 'fixed',
        top: 48,
        left: sidebarWidth,
        right: 0,
        zIndex: 1100,
        transition: (theme) =>
          theme.transitions.create('left', {
            easing: theme.transitions.easing.sharp,
            duration: theme.transitions.duration.leavingScreen,
          }),
      }}>
        <TabsBar />
      </Box>
      {children}
      <CheckForApplicationUpdate />
    </RALayout>
  );
};
export const Layout = ({ children }) => (
  <RALayout
    appBar={AppBar}
    menu={MyMenu}
    sx={{ '& .RaLayout-content': { position: 'absolute', left: '200px', overflowY: 'auto', width: 'calc(100% - 200px)', height: 'calc(100% - 50px)' } }}
  >
    {children}
    <CheckForApplicationUpdate />
  </RALayout>
  <LayoutContent>{children}</LayoutContent>
);