From 01ea11a8e7a217b51208ae31ca7bd4c7cd13f7fa Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期四, 08 一月 2026 12:37:49 +0800
Subject: [PATCH] #tabsBar

---
 rsf-admin/src/layout/TabsBar.jsx |  364 +++++++++++++++++++++++++++++++++++++++++++++
 rsf-admin/src/layout/index.jsx   |   59 ++++++-
 2 files changed, 413 insertions(+), 10 deletions(-)

diff --git a/rsf-admin/src/layout/TabsBar.jsx b/rsf-admin/src/layout/TabsBar.jsx
new file mode 100644
index 0000000..bd77113
--- /dev/null
+++ b/rsf-admin/src/layout/TabsBar.jsx
@@ -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'];
+
+// 浠巐ocalStorage鑾峰彇宸蹭繚瀛樼殑鏍囩椤�
+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);
+    }
+};
+
+// 瑙勮寖鍖栬矾寰� - 澶勭悊甯D鐨勮鎯�/缂栬緫椤甸潰
+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);
+    };
+
+
+
+    // 鍏抽棴鎵�鏈夋爣绛鹃〉锛堥櫎浜哾ashboard锛�
+    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;
diff --git a/rsf-admin/src/layout/index.jsx b/rsf-admin/src/layout/index.jsx
index 9d105ce..293ae54 100644
--- a/rsf-admin/src/layout/index.jsx
+++ b/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>
 );

--
Gitblit v1.9.1