实现多标签栏

点击查看代码
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Tabs, Icon } from 'antd';
import { KeepAlive } from 'react-activation';
import { TabManager } from 'utils/tabs';
import './style.less';

const { TabPane } = Tabs;

// 你项目里如果已有单例 tabManager,请改成你自己的获取方式
const tabManager = new TabManager();

// 定义标签页图标映射表(antd3 Icon.type)
const tabIconMap = {
  '/home': 'home',
  '/preSalesPolicy': 'file-done',
  '/onlineInsurance': 'safety',
  '/insurance': 'insurance', // 如果你的 antd3 不支持该 type,会自动降级
  '/business': 'file-search',
  '/systemInfoManage': 'apartment',
  '/dataSourceManage': 'line-chart',
  '/informationOverview': 'desktop',
  '/dictionarySearch': 'read',
  '/dictionaryMaintenance': 'tool',
  '/changeDetails': 'pull-request',
  '/collectionHistory': 'file-search',
  '/exceptionTable': 'control',
  '/contrastDifferences': 'block',
  '/timedTask': 'clock-circle',
  '/systemConfiguration': 'setting',
  '/email': 'code',
  '/release': 'interaction',
  '/collectionManagement': 'control',
  '/contentManagement': 'database',
  '/dictionaryProblemFeedback': 'profile',
  '/userAccessStatistics': 'idcard',
  '/problemFeedback': 'calendar',
  '/datastandardLibrary': 'read',
  '/systemManagement': 'user',
};

const DEFAULT_ICON = 'property-safety';

const getIconForPath = (path) => {
  if (tabIconMap[path]) return tabIconMap[path];

  let matchedIcon = null;
  let matchedLength = 0;
  Object.keys(tabIconMap).forEach((key) => {
    if (path.startsWith(key) && (path.length === key.length || path[key.length] === '/')) {
      if (key.length > matchedLength) {
        matchedIcon = tabIconMap[key];
        matchedLength = key.length;
      }
    }
  });
  return matchedIcon || DEFAULT_ICON;
};

const clamp = (v, min, max) => Math.max(min, Math.min(max, v));

const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);

const smoothScrollLeft = (el, targetLeft, duration = 180) => {
  if (!el) return;
  const startLeft = el.scrollLeft;
  const maxLeft = Math.max(0, el.scrollWidth - el.clientWidth);
  const to = clamp(targetLeft, 0, maxLeft);
  if (Math.abs(to - startLeft) < 1) return;

  const start = Date.now();
  const tick = () => {
    const t = clamp((Date.now() - start) / duration, 0, 1);
    el.scrollLeft = startLeft + (to - startLeft) * easeInOutCubic(t);
    if (t < 1) requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
};

const ensureTabVisible = (container, tabEl) => {
  if (!container || !tabEl) return;

  const containerRect = container.getBoundingClientRect();
  const tabRect = tabEl.getBoundingClientRect();

  const leftOverflow = tabRect.left - containerRect.left;
  const rightOverflow = tabRect.right - containerRect.right;

  if (leftOverflow < 0) {
    smoothScrollLeft(container, container.scrollLeft + leftOverflow - 12);
  } else if (rightOverflow > 0) {
    smoothScrollLeft(container, container.scrollLeft + rightOverflow + 12);
  }
};

const CustomTabBar = ({
  activeKey,
  panes,
  onTabClick,
  onTabRemove,
  onContextMenu,
}) => {
  const scrollRef = useRef(null);
  const rafRef = useRef(null);

  const [canScrollLeft, setCanScrollLeft] = useState(false);
  const [canScrollRight, setCanScrollRight] = useState(false);

  const updateScrollState = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;

    const maxLeft = Math.max(0, el.scrollWidth - el.clientWidth);
    const left = el.scrollLeft;

    setCanScrollLeft(left > 0);
    setCanScrollRight(left < maxLeft - 1);
  }, []);

  const scheduleUpdateScrollState = useCallback(() => {
    if (rafRef.current) return;
    rafRef.current = requestAnimationFrame(() => {
      rafRef.current = null;
      updateScrollState();
    });
  }, [updateScrollState]);

  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;

    const activeEl = el.querySelector(`.tab-button[data-key="${activeKey}"]`);
    ensureTabVisible(el, activeEl);
    updateScrollState();
  }, [activeKey, panes.length, updateScrollState]);

  useEffect(() => {
    const onResize = () => {
      const el = scrollRef.current;
      if (!el) return;
      const activeEl = el.querySelector(`.tab-button[data-key="${activeKey}"]`);
      ensureTabVisible(el, activeEl);
      updateScrollState();
    };
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, [activeKey, updateScrollState]);

  const scrollByAmount = (amount) => {
    const el = scrollRef.current;
    if (!el) return;
    smoothScrollLeft(el, el.scrollLeft + amount);
  };

  return (
    <div className="custom-tabs-rail">
      {canScrollLeft && (
        <div className="tab-scroll-hint left">
          <button
            type="button"
            className="tab-scroll-btn"
            onClick={() => scrollByAmount(-Math.max(160, (scrollRef.current?.clientWidth || 0) * 0.6))}
            aria-label="scroll left"
          >
            <Icon type="left" />
          </button>
        </div>
      )}

      {canScrollRight && (
        <div className="tab-scroll-hint right">
          <button
            type="button"
            className="tab-scroll-btn"
            onClick={() => scrollByAmount(Math.max(160, (scrollRef.current?.clientWidth || 0) * 0.6))}
            aria-label="scroll right"
          >
            <Icon type="right" />
          </button>
        </div>
      )}

      <div
        ref={scrollRef}
        className="custom-tabs-bar"
        onScroll={scheduleUpdateScrollState}
        onWheel={(e) => {
          const el = scrollRef.current;
          if (!el) return;

          // 将竖向滚轮映射成横向滚动(在缩放/窄屏时非常关键)
          if (e.deltaY) {
            el.scrollLeft += e.deltaY;
            e.preventDefault();
            scheduleUpdateScrollState();
          }
        }}
      >
        <div className="tabs-wrapper">
          {panes.map((pane, index) => (
            <React.Fragment key={pane.key}>
              <div
                data-key={pane.key}
                onClick={() => onTabClick(pane.key)}
                onContextMenu={(e) => {
                  onContextMenu(e, pane.key);
                  e.preventDefault();
                }}
                className={`tab-button ${pane.key === activeKey ? 'active' : ''}`}
                title={typeof pane.title === 'string' ? pane.title : ''}
              >
                <span className="tab-content-wrapper">
                  <Icon type={getIconForPath(pane.key)} className="tab-icon" />
                  <span className="tab-title">{pane.title}</span>
                </span>

                {pane.closable !== false && (
                  <Icon
                    type="close"
                    className="ant-tabs-close-x"
                    onClick={(e) => {
                      e.stopPropagation();
                      onTabRemove(pane.key);
                    }}
                  />
                )}
              </div>

              {index < panes.length - 1 && <div className="tab-divider" />}
            </React.Fragment>
          ))}
        </div>
      </div>
    </div>
  );
};

const TabComponent = ({ onTabClick }) => {
  const [state, setState] = useState(tabManager.getState());
  const [contextMenu, setContextMenu] = useState({ visible: false, x: 0, y: 0, key: '' });
  const isUpdating = useRef(false);

  useEffect(() => {
    const unsubscribe = tabManager.subscribe((newState) => {
      if (!isUpdating.current) {
        isUpdating.current = true;
        setState({ ...newState });
        setTimeout(() => {
          isUpdating.current = false;
        }, 0);
      }
    });

    const handleClick = (e) => {
      const menuElement = document.querySelector('.tab-context-menu');
      if (contextMenu.visible && (!menuElement || !menuElement.contains(e.target))) {
        setContextMenu({ visible: false, x: 0, y: 0, key: '' });
      }
    };

    const handleWindowBlur = () => {
      if (contextMenu.visible) {
        setContextMenu({ visible: false, x: 0, y: 0, key: '' });
      }
    };

    document.addEventListener('click', handleClick);
    window.addEventListener('blur', handleWindowBlur);

    return () => {
      unsubscribe();
      document.removeEventListener('click', handleClick);
      window.removeEventListener('blur', handleWindowBlur);
    };
  }, [contextMenu.visible]);

  const onChange = (activeKey) => {
    tabManager.activatePane(activeKey);
    if (onTabClick) onTabClick(activeKey);
  };

  const onEdit = (targetKey, action) => {
    if (action === 'remove') tabManager.removePane(targetKey);
  };

  const refreshCurrentTab = (key) => {
    const paneToRefresh = state.panes.find((pane) => pane.key === key);
    if (paneToRefresh) {
      const { title, ...rest } = paneToRefresh;
      tabManager.removePane(key);
      setTimeout(() => {
        tabManager.addPane({ key, title, ...rest });
      }, 0);
    }
    setContextMenu({ visible: false, x: 0, y: 0, key: '' });
  };

  const closeCurrentTab = (key) => {
    tabManager.removePane(key);
    setContextMenu({ visible: false, x: 0, y: 0, key: '' });
  };

  const closeOtherTabs = (key) => {
    const keysToClose = state.panes
      .filter((pane) => pane.key !== key && pane.closable !== false)
      .map((pane) => pane.key);

    keysToClose.forEach((k) => tabManager.removePane(k));
    setContextMenu({ visible: false, x: 0, y: 0, key: '' });
  };

  const closeAllTabs = () => {
    const keysToClose = state.panes
      .filter((pane) => pane.closable !== false)
      .map((pane) => pane.key);

    keysToClose.forEach((k) => tabManager.removePane(k));
    setContextMenu({ visible: false, x: 0, y: 0, key: '' });
  };

  const handleContextMenu = (e, key) => {
    e.preventDefault();
    e.stopPropagation();

    const menuWidth = 160;
    const menuHeight = 150;
    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;

    let x = e.clientX;
    let y = e.clientY;

    if (x + menuWidth > windowWidth) x = windowWidth - menuWidth - 5;
    if (y + menuHeight > windowHeight) y = windowHeight - menuHeight - 5;

    setContextMenu({ visible: true, x, y, key });
    return false;
  };

  return (
    <div className="tabs-container">
      {state.panes.length > 0 ? (
        <Tabs
          hideAdd
          onChange={onChange}
          activeKey={state.activeKey}
          type="editable-card"
          onEdit={onEdit}
          renderTabBar={() => (
            <CustomTabBar
              activeKey={state.activeKey}
              panes={state.panes}
              onTabClick={onChange}
              onTabRemove={(targetKey) => onEdit(targetKey, 'remove')}
              onContextMenu={handleContextMenu}
            />
          )}
        >
          {state.panes.map((pane) => (
            <TabPane key={pane.key} closable={pane.closable !== false}>
              {state.activeKey === pane.key ? (
                pane.key === '/dataAsset/reportQuery' || pane.key === '/dataAsset/indicatorLibrary' ? (
                  pane.content
                ) : (
                  <KeepAlive cacheKey={pane.key} when saveScrollPosition="screen">
                    {pane.content}
                  </KeepAlive>
                )
              ) : (
                <div style={{ display: 'none' }}>
                  {pane.key === '/dataAsset/reportQuery' || pane.key === '/dataAsset/indicatorLibrary' ? (
                    pane.content
                  ) : (
                    <KeepAlive cacheKey={pane.key} when saveScrollPosition="screen">
                      {pane.content}
                    </KeepAlive>
                  )}
                </div>
              )}
            </TabPane>
          ))}
        </Tabs>
      ) : (
        <div className="tabs-placeholder">暂无标签页</div>
      )}

      {contextMenu.visible && (
        <div
          className="tab-context-menu"
          style={{
            position: 'fixed',
            left: `${contextMenu.x}px`,
            top: `${contextMenu.y}px`,
            zIndex: 9999,
            background: '#fff',
            minWidth: '160px',
            boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
            borderRadius: '4px',
            padding: '4px 0',
          }}
        >
          <div style={{ padding: '4px 0' }}>
            <div
              className="tab-menu-item"
              onClick={(e) => {
                e.stopPropagation();
                refreshCurrentTab(contextMenu.key);
              }}
            >
              <Icon type="reload" style={{ marginRight: 8 }} />
              <span>刷新标签页</span>
            </div>

            <div
              className="tab-menu-item"
              onClick={(e) => {
                e.stopPropagation();
                closeCurrentTab(contextMenu.key);
              }}
            >
              <Icon type="close" style={{ marginRight: 8 }} />
              <span>关闭标签页</span>
            </div>

            <div
              className="tab-menu-item"
              onClick={(e) => {
                e.stopPropagation();
                closeOtherTabs(contextMenu.key);
              }}
            >
              <Icon type="close-circle" style={{ marginRight: 8 }} />
              <span>关闭其他标签页</span>
            </div>

            <div
              className="tab-menu-item"
              onClick={(e) => {
                e.stopPropagation();
                closeAllTabs();
              }}
            >
              <Icon type="close-circle" style={{ marginRight: 8 }} />
              <span>关闭所有标签页</span>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

export default TabComponent;

点击查看代码
.tabs-container {
  height: 100%;
  display: flex;
  flex-direction: column;

  .tabs-placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    color: #999;
    font-size: 16px;
  }

  // 隐藏 antd 默认 tab bar(你自定义 tab bar)
  :global(.ant-tabs-top > .ant-tabs-bar),
  :global(.ant-tabs-bar) {
    display: none;
  }

  // 保留 antd 内容区布局正常
  :global(.ant-tabs) {
    height: 100%;
    display: flex;
    flex-direction: column;
  }
  :global(.ant-tabs-content) {
    flex: 1;
    min-height: 0;
  }
}

/* ===== Chrome 风格滚动提示层 ===== */
.custom-tabs-rail {
  position: relative;
  height: 40px;
  background: #f8f9fa;
  border-bottom: 1px solid #e9ecef;
}

/* 可滚动容器:必须 width:100% 才不会缩放时丢失右侧可视 */
.custom-tabs-bar {
  height: 40px;
  width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  box-sizing: border-box;

  scrollbar-width: none; // Firefox
  -ms-overflow-style: none; // IE/Edge

  &::-webkit-scrollbar {
    display: none;
  }
}

/* 内容必须“按内容撑开”,避免 flex 压缩造成裁切错觉 */
.tabs-wrapper {
  display: flex;
  align-items: center;
  height: 40px;
  min-width: max-content;
}

.tab-button {
  flex: 0 0 auto;
  padding: 10px 20px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  font-weight: 600;
  color: #6c757d;
  transition: all 0.2s ease;
  position: relative;
  outline: none;
  display: flex;
  align-items: center;
  gap: 6px;
  height: 40px;
  box-sizing: border-box;
  user-select: none;

  &:hover {
    background: #e9ecef;
    color: #495057;

    .ant-tabs-close-x {
      opacity: 1;
    }
  }

  &.active {
    color: #e30613;

    &::after {
      content: '';
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 3px;
      background: #e30613;
      border-radius: 3px 3px 0 0;
    }
  }
}

.tab-content-wrapper {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}

.tab-title {
  max-width: 220px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.tab-icon {
  font-size: 14px;
}

.ant-tabs-close-x {
  margin-left: 6px;
  font-size: 14px;
  opacity: 0.7;
  transition: opacity 0.2s ease;
  cursor: pointer;

  &:hover {
    opacity: 1 !important;
    background-color: rgba(0, 0, 0, 0.05);
    border-radius: 50%;
  }
}

.tab-divider {
  width: 1px;
  background: #dad5bd;
  margin: auto 0;
  height: 50%;
  flex-shrink: 0;
}

/* 左右渐隐 + 箭头 */
.tab-scroll-hint {
  position: absolute;
  top: 0;
  height: 40px;
  width: 44px;
  z-index: 3;
  display: flex;
  align-items: center;
  pointer-events: none; // 渐隐层不挡滚动;按钮再开 pointer-events

  &.left {
    left: 0;
    background: linear-gradient(to right, #f8f9fa 70%, rgba(248, 249, 250, 0));
    justify-content: flex-start;
    padding-left: 4px;
  }

  &.right {
    right: 0;
    background: linear-gradient(to left, #f8f9fa 70%, rgba(248, 249, 250, 0));
    justify-content: flex-end;
    padding-right: 4px;
  }
}

.tab-scroll-btn {
  pointer-events: auto;
  width: 28px;
  height: 28px;
  border: 1px solid #e9ecef;
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.9);
  color: #495057;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  outline: none;

  &:hover {
    background: #ffffff;
    border-color: #dcdfe3;
  }
}

/* 右键菜单小样式 */
.tab-context-menu {
  .tab-menu-item {
    display: flex;
    align-items: center;
    height: 32px;
    padding: 0 12px;
    cursor: pointer;

    &:hover {
      background: #e6f7ff;
    }
  }
}

posted @ 2025-12-23 23:17  牛久安  阅读(3)  评论(0)    收藏  举报