From 4e40afebef0f4eaa846f9c02bd3243c863d0a0e7 Mon Sep 17 00:00:00 2001
From: 1 <1@123>
Date: 星期三, 11 三月 2026 14:10:38 +0800
Subject: [PATCH] lsh#

---
 rsf-admin/src/layout/TabsBar.jsx |  324 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 323 insertions(+), 1 deletions(-)

diff --git a/rsf-admin/src/layout/TabsBar.jsx b/rsf-admin/src/layout/TabsBar.jsx
index c346104..26d0c35 100644
--- a/rsf-admin/src/layout/TabsBar.jsx
+++ b/rsf-admin/src/layout/TabsBar.jsx
@@ -1,7 +1,7 @@
 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 { 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';
@@ -79,6 +79,8 @@
     return normalizePath(path1) === normalizePath(path2);
 };
 
+const LONG_PRESS_MS = 300;
+
 const TabsBar = () => {
     const location = useLocation();
     const navigate = useNavigate();
@@ -86,6 +88,42 @@
     const { permissions } = usePermissions();
     const [tabs, setTabs] = useState(getSavedTabs);
     const pendingNavigationRef = useRef(null);
+    const tabsBarRef = useRef(null);
+    const [contextMenu, setContextMenu] = useState(null);
+    const [contextMenuTab, setContextMenuTab] = useState(null);
+    const contextMenuOpenRef = useRef(false);
+    contextMenuOpenRef.current = contextMenu !== null;
+
+    const [draggingIndex, setDraggingIndex] = useState(null);
+    const [dropIndicatorIndex, setDropIndicatorIndex] = useState(null);
+    const longPressTimerRef = useRef(null);
+    const longPressIndexRef = useRef(null);
+    const justFinishedDragRef = useRef(false);
+
+    // 鍦ㄦ爣绛鹃〉鍙抽敭锛岄樆姝㈡祻瑙堝櫒榛樿鑿滃崟
+    useEffect(() => {
+        const onDocContextMenu = (e) => {
+            const inTabsBarByTarget = tabsBarRef.current && tabsBarRef.current.contains(e.target);
+            let inTabsBarByPos = false;
+            if (tabsBarRef.current) {
+                const rect = tabsBarRef.current.getBoundingClientRect();
+                inTabsBarByPos = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
+            }
+            const inTabsBar = inTabsBarByTarget || inTabsBarByPos;
+            const menuOpen = contextMenuOpenRef.current;
+            if (inTabsBar || menuOpen) {
+                e.preventDefault();
+            }
+        };
+        document.addEventListener('contextmenu', onDocContextMenu, true);
+        return () => document.removeEventListener('contextmenu', onDocContextMenu, true);
+    }, []); // 鐢� ref 璇绘渶鏂扮姸鎬侊紝閬垮厤蹇�熺偣鍑婚棴鍖呮粸鍚�
+
+    // 缈昏瘧杈呭姪锛屾棤缈昏瘧 key 鏃惰繑鍥為粯璁ゅ��
+    const t = useCallback((key, defaultValue) => {
+        const translated = translate(key);
+        return translated === key ? defaultValue : translated;
+    }, [translate]);
 
     // 澶勭悊寰呮墽琛岀殑瀵艰埅
     useEffect(() => {
@@ -196,6 +234,10 @@
 
     // 鍒囨崲鏍囩椤�
     const handleTabChange = (event, newValue) => {
+        if (justFinishedDragRef.current) {
+            justFinishedDragRef.current = false;
+            return;
+        }
         const targetTab = tabs[newValue];
         if (targetTab && targetTab.path !== location.pathname) {
             navigate(targetTab.path);
@@ -240,6 +282,126 @@
         }
     };
 
+    // 鍏抽棴鍏朵粬鏍囩
+    const handleCloseOthers = (keepTabPath) => {
+        const keepTab = tabs.find(tab => tab.path === keepTabPath);
+        if (!keepTab) return;
+        const dashboardTab = tabs.find(tab => tab.path === '/dashboard');
+        const newTabs = [];
+        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;
+        let newTabs = tabs.slice(keepTabIndex);
+        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();
+        if (!tab) return;
+        const tabObj = tabs.find(t => t.path === tab.path || isSameResource(t.path, tab.path));
+        if (!tabObj) return;
+        const hasItems = tabObj.closable ||
+            canCloseLeftForTab(tab.path) ||
+            canCloseRightForTab(tab.path) ||
+            canCloseOthersForTab(tab.path);
+        if (!hasItems) return;
+        setContextMenu({ mouseX: event.clientX + 2, mouseY: event.clientY - 6 });
+        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 canCloseOthersForTab = (tabPath) => {
+        return tabs.filter(tab => {
+            const isTarget = tab.path === tabPath || isSameResource(tab.path, tabPath);
+            return !isTarget && tab.closable;
+        }).length > 0;
+    };
+    const canCloseLeftForTab = (tabPath) => {
+        const tabIndex = tabs.findIndex(tab => tab.path === tabPath || isSameResource(tab.path, tabPath));
+        if (tabIndex <= 0) return false;
+        return tabs.slice(0, tabIndex).some(tab => tab.closable);
+    };
+    const canCloseRightForTab = (tabPath) => {
+        const tabIndex = tabs.findIndex(tab => tab.path === tabPath || isSameResource(tab.path, tabPath));
+        if (tabIndex < 0 || tabIndex >= tabs.length - 1) return false;
+        return tabs.slice(tabIndex + 1).some(tab => tab.closable);
+    };
+
     // 鑾峰彇褰撳墠鏍囩椤电储寮�
     const currentTabIndex = tabs.findIndex(tab =>
         tab.path === location.pathname || isSameResource(tab.path, location.pathname)
@@ -249,8 +411,110 @@
     // 鍒ゆ柇鏄惁鏈夊彲鍏抽棴鐨勬爣绛�
     const hasClosableTabs = tabs.some(tab => tab.closable);
 
+    // 鍙抽敭鎵撳紑鏃堕伄缃╁鐞�
+    const menuPaperRef = useRef(null);
+    useEffect(() => {
+        if (contextMenu === null) return;
+        const onDocClick = (e) => {
+            const inMenu = menuPaperRef.current && menuPaperRef.current.contains(e.target);
+            const inTabsBar = tabsBarRef.current && tabsBarRef.current.contains(e.target);
+            if (!inMenu && !inTabsBar) {
+                setContextMenu(null);
+                setContextMenuTab(null);
+            }
+        };
+        document.addEventListener('mousedown', onDocClick, true);
+        return () => document.removeEventListener('mousedown', onDocClick, true);
+    }, [contextMenu]);
+
+    const clearLongPressTimer = useCallback(() => {
+        if (longPressTimerRef.current) {
+            clearTimeout(longPressTimerRef.current);
+            longPressTimerRef.current = null;
+        }
+        longPressIndexRef.current = null;
+    }, []);
+
+    const handleTabPointerDown = useCallback((e, index) => {
+        if (index < 0) return;
+        longPressIndexRef.current = index;
+        longPressTimerRef.current = setTimeout(() => {
+            longPressTimerRef.current = null;
+            setDraggingIndex(index);
+            setDropIndicatorIndex(index);
+        }, LONG_PRESS_MS);
+    }, []);
+
+    const handleTabPointerUp = useCallback(() => {
+        clearLongPressTimer();
+    }, [clearLongPressTimer]);
+
+    useEffect(() => {
+        if (draggingIndex === null) return;
+        const getDropIndex = (clientX) => {
+            const nodes = tabsBarRef.current?.querySelectorAll('[data-tab-index]');
+            if (!nodes?.length) return draggingIndex;
+            for (let i = 0; i < nodes.length; i++) {
+                const rect = nodes[i].getBoundingClientRect();
+                const mid = rect.left + rect.width / 2;
+                if (clientX <= mid) {
+                    const drop = i;
+                    if (draggingIndex === 0) return drop <= 0 ? 0 : draggingIndex;
+                    return drop <= 0 ? 1 : drop;
+                }
+            }
+            const drop = nodes.length;
+            return draggingIndex === 0 ? 0 : drop;
+        };
+        const clientXFromEvent = (e) => e.clientX ?? e.touches?.[0]?.clientX;
+        const onMove = (e) => {
+            const x = clientXFromEvent(e);
+            if (x != null) setDropIndicatorIndex(getDropIndex(x));
+        };
+        const onTouchMove = (e) => {
+            e.preventDefault();
+            onMove(e);
+        };
+        const onUp = () => {
+            justFinishedDragRef.current = true;
+            setDraggingIndex((di) => {
+                setDropIndicatorIndex((dropIdx) => {
+                    if (di !== null && dropIdx !== null && di !== dropIdx) {
+                        const newTabs = [...tabs];
+                        const [item] = newTabs.splice(di, 1);
+                        const insertAt = dropIdx > di ? dropIdx - 1 : dropIdx;
+                        newTabs.splice(insertAt, 0, item);
+                        const dashboard = newTabs.find((t) => t.path === '/dashboard');
+                        if (dashboard && newTabs[0].path !== '/dashboard') {
+                            const idx = newTabs.indexOf(dashboard);
+                            newTabs.splice(idx, 1);
+                            newTabs.unshift(dashboard);
+                        }
+                        saveTabs(newTabs);
+                        setTabs(newTabs);
+                    }
+                    return null;
+                });
+                return null;
+            });
+        };
+        document.addEventListener('mousemove', onMove, true);
+        document.addEventListener('mouseup', onUp, true);
+        document.addEventListener('touchmove', onTouchMove, { passive: false, capture: true });
+        document.addEventListener('touchend', onUp, true);
+        document.addEventListener('touchcancel', onUp, true);
+        return () => {
+            document.removeEventListener('mousemove', onMove, true);
+            document.removeEventListener('mouseup', onUp, true);
+            document.removeEventListener('touchmove', onTouchMove, true);
+            document.removeEventListener('touchend', onUp, true);
+            document.removeEventListener('touchcancel', onUp, true);
+        };
+    }, [draggingIndex, tabs]);
+
     return (
         <Box
+            ref={tabsBarRef}
             sx={{
                 width: '100%',
                 backgroundColor: '#fff',
@@ -282,10 +546,20 @@
                         key={tab.path}
                         label={
                             <Box
+                                data-tab-index={index}
+                                onContextMenu={(e) => handleContextMenu(e, tab)}
+                                onMouseDown={(e) => handleTabPointerDown(e, index)}
+                                onMouseUp={handleTabPointerUp}
+                                onMouseLeave={handleTabPointerUp}
+                                onTouchStart={(e) => handleTabPointerDown(e, index)}
+                                onTouchEnd={handleTabPointerUp}
+                                onTouchCancel={handleTabPointerUp}
                                 sx={{
                                     display: 'flex',
                                     alignItems: 'center',
                                     gap: 0.5,
+                                    width: '100%',
+                                    ...(draggingIndex === index && { opacity: 0.7 }),
                                 }}
                             >
                                 {tab.path === '/dashboard' && (
@@ -362,6 +636,54 @@
                     </Tooltip>
                 </Box>
             )}
+            {/* 鍙抽敭鑿滃崟 */}
+            <Menu
+                open={contextMenu !== null}
+                onClose={handleCloseContextMenu}
+                anchorReference="anchorPosition"
+                anchorPosition={
+                    contextMenu !== null
+                        ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
+                        : undefined
+                }
+                disableScrollLock
+                ModalProps={{
+                    disablePortal: true,
+                    BackdropProps: { sx: { pointerEvents: 'none' } }, // 涓嶆嫤鍙抽敭锛屼究浜庡啀鍙抽敭鍙︿竴涓爣绛�
+                }}
+                PaperProps={{
+                    ref: menuPaperRef,
+                    sx: {
+                        minWidth: 120,
+                        borderRadius: '8px',
+                        boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
+                        mt: 0.5,
+                        py: 0.5,
+                    },
+                }}
+                MenuListProps={{ sx: { py: 0 } }}
+            >
+                {/*{contextMenuTab && contextMenuTab.closable && (*/}
+                {/*    <MenuItem onClick={handleCloseCurrentTab} sx={{ fontSize: '0.8125rem', py: 0.75, px: 1.5, minHeight: 'auto' }}>*/}
+                {/*        {t('ra.action.close', '鍏抽棴褰撳墠鏍囩')}*/}
+                {/*    </MenuItem>*/}
+                {/*)}*/}
+                {contextMenuTab && canCloseLeftForTab(contextMenuTab.path) && (
+                    <MenuItem onClick={handleCloseLeftTabs} sx={{ fontSize: '0.8125rem', py: 0.75, px: 1.5, minHeight: 'auto' }}>
+                        {t('ra.action.closeLeft', '鍏抽棴宸︿晶鏍囩')}
+                    </MenuItem>
+                )}
+                {contextMenuTab && canCloseRightForTab(contextMenuTab.path) && (
+                    <MenuItem onClick={handleCloseRightTabs} sx={{ fontSize: '0.8125rem', py: 0.75, px: 1.5, minHeight: 'auto' }}>
+                        {t('ra.action.closeRight', '鍏抽棴鍙充晶鏍囩')}
+                    </MenuItem>
+                )}
+                {contextMenuTab && canCloseOthersForTab(contextMenuTab.path) && (
+                    <MenuItem onClick={handleCloseOtherTabs} sx={{ fontSize: '0.8125rem', py: 0.75, px: 1.5, minHeight: 'auto' }}>
+                        {t('ra.action.closeOthers', '鍏抽棴鍏朵粬鏍囩')}
+                    </MenuItem>
+                )}
+            </Menu>
         </Box>
     );
 };

--
Gitblit v1.9.1