点击查看代码
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;
}
}
}