feat(标签页): 实现多标签页组件功能

- 新增 TabBar 主组件,支持标签页展示和交互
- 新增 ContextMenu 右键菜单组件
- 实现圆角胶囊样式设计,渐变色激活态
- 支持点击标签切换路由
- 支持关闭标签功能,首页标签不可关闭
- 支持右键菜单:重新加载、关闭左侧/右侧/其他标签
- 添加流畅的过渡动画和 hover 效果
This commit is contained in:
gaoziman 2025-11-07 22:36:15 +08:00
parent e8cacc2c7a
commit 2d63d8d5e4
3 changed files with 354 additions and 0 deletions

View File

@ -0,0 +1,71 @@
import React from 'react';
import { Dropdown, Menu } from '@arco-design/web-react';
import {
IconRefresh,
IconClose,
IconLeft,
IconRight,
IconCopy,
} from '@arco-design/web-react/icon';
import { ContextMenuType } from '@/types/tabs';
interface ContextMenuProps {
/** 右键点击的标签 key */
targetKey: string;
/** 是否可以关闭左侧 */
canCloseLeft: boolean;
/** 是否可以关闭右侧 */
canCloseRight: boolean;
/** 是否可以关闭其他 */
canCloseOthers: boolean;
/** 菜单操作回调 */
onMenuClick: (type: ContextMenuType, key: string) => void;
/** 子元素 */
children: React.ReactNode;
}
const ContextMenu: React.FC<ContextMenuProps> = ({
targetKey,
canCloseLeft,
canCloseRight,
canCloseOthers,
onMenuClick,
children,
}) => {
const handleMenuClick = (key: string) => {
onMenuClick(key as ContextMenuType, targetKey);
};
const dropList = (
<Menu onClickMenuItem={handleMenuClick}>
<Menu.Item key={ContextMenuType.RELOAD}>
<IconRefresh style={{ marginRight: 8 }} />
</Menu.Item>
<Menu.Item key={ContextMenuType.CLOSE_CURRENT}>
<IconClose style={{ marginRight: 8 }} />
</Menu.Item>
<Menu.Item key={ContextMenuType.CLOSE_LEFT} disabled={!canCloseLeft}>
<IconLeft style={{ marginRight: 8 }} />
</Menu.Item>
<Menu.Item key={ContextMenuType.CLOSE_RIGHT} disabled={!canCloseRight}>
<IconRight style={{ marginRight: 8 }} />
</Menu.Item>
<Menu.Item key={ContextMenuType.CLOSE_OTHERS} disabled={!canCloseOthers}>
<IconCopy style={{ marginRight: 8 }} />
</Menu.Item>
</Menu>
);
return (
<Dropdown droplist={dropList} trigger="contextMenu" position="bl">
{children}
</Dropdown>
);
};
export default ContextMenu;

View File

@ -0,0 +1,105 @@
.tab-bar {
display: flex;
align-items: center;
height: 48px;
background-color: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
padding: 0 16px;
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-border-3);
border-radius: 2px;
&:hover {
background-color: var(--color-border-4);
}
}
.tab-list {
display: flex;
align-items: center;
gap: 8px;
height: 100%;
}
.tab-item {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 16px;
background-color: var(--color-fill-2);
border-radius: 16px;
cursor: pointer;
user-select: none;
transition: all 0.3s cubic-bezier(0.34, 0.69, 0.1, 1);
white-space: nowrap;
font-size: 14px;
color: var(--color-text-2);
&:hover {
background-color: var(--color-fill-3);
transform: translateY(-1px);
}
&.tab-item-active {
background: linear-gradient(
135deg,
rgb(var(--primary-6)) 0%,
rgb(var(--primary-5)) 100%
);
color: #fff;
font-weight: 500;
box-shadow: 0 2px 8px rgba(var(--primary-6), 0.3);
&:hover {
background: linear-gradient(
135deg,
rgb(var(--primary-5)) 0%,
rgb(var(--primary-4)) 100%
);
box-shadow: 0 4px 12px rgba(var(--primary-6), 0.4);
}
.tab-close {
color: rgba(255, 255, 255, 80%);
&:hover {
color: #fff;
background-color: rgba(255, 255, 255, 20%);
}
}
}
.tab-title {
display: inline-block;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 8px;
border-radius: 50%;
color: var(--color-text-3);
transition: all 0.2s;
font-size: 12px;
&:hover {
color: var(--color-text-1);
background-color: var(--color-fill-3);
}
}
}
}

View File

@ -0,0 +1,178 @@
import React from 'react';
import { IconClose } from '@arco-design/web-react/icon';
import { useHistory } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { GlobalState } from '@/store';
import { ContextMenuType } from '@/types/tabs';
import useLocale from '@/utils/useLocale';
import ContextMenu from './ContextMenu';
import styles from './index.module.less';
const TabBar: React.FC = () => {
const history = useHistory();
const dispatch = useDispatch();
const locale = useLocale();
const { tabs, activeKey } = useSelector((state: GlobalState) => state.tabs);
// 点击标签页切换路由
const handleTabClick = (key: string) => {
const targetTab = tabs.find((tab) => tab.key === key);
if (targetTab) {
dispatch({
type: 'tabs/setActiveTab',
payload: key,
});
history.push(targetTab.path);
}
};
// 关闭标签页
const handleTabClose = (e: React.MouseEvent, key: string) => {
e.stopPropagation();
const targetTab = tabs.find((tab) => tab.key === key);
if (!targetTab || !targetTab.closable) {
return;
}
const targetIndex = tabs.findIndex((tab) => tab.key === key);
dispatch({
type: 'tabs/removeTab',
payload: key,
});
// 如果关闭的是当前激活的标签,需要切换到相邻标签
if (activeKey === key && tabs.length > 1) {
let nextTab;
if (targetIndex < tabs.length - 1) {
nextTab = tabs[targetIndex + 1];
} else {
nextTab = tabs[targetIndex - 1];
}
history.push(nextTab.path);
}
};
// 右键菜单操作
const handleContextMenu = (type: ContextMenuType, targetKey: string) => {
const targetTab = tabs.find((tab) => tab.key === targetKey);
if (!targetTab) {
return;
}
switch (type) {
case ContextMenuType.RELOAD:
// 重新加载页面
window.location.reload();
break;
case ContextMenuType.CLOSE_CURRENT:
// 关闭当前标签
if (targetTab.closable) {
const e = new MouseEvent('click') as any;
handleTabClose(e, targetKey);
}
break;
case ContextMenuType.CLOSE_LEFT:
// 关闭左侧标签
dispatch({
type: 'tabs/closeLeftTabs',
payload: targetKey,
});
break;
case ContextMenuType.CLOSE_RIGHT:
// 关闭右侧标签
dispatch({
type: 'tabs/closeRightTabs',
payload: targetKey,
});
break;
case ContextMenuType.CLOSE_OTHERS:
// 关闭其他标签
dispatch({
type: 'tabs/closeOtherTabs',
payload: targetKey,
});
history.push(targetTab.path);
break;
default:
break;
}
};
// 计算右键菜单的可用状态
const getContextMenuProps = (targetKey: string) => {
const targetIndex = tabs.findIndex((tab) => tab.key === targetKey);
// 检查左侧是否有可关闭的标签
const canCloseLeft = tabs.slice(0, targetIndex).some((tab) => tab.closable);
// 检查右侧是否有可关闭的标签
const canCloseRight = tabs
.slice(targetIndex + 1)
.some((tab) => tab.closable);
// 检查是否有其他可关闭的标签
const canCloseOthers = tabs.some(
(tab) => tab.key !== targetKey && tab.closable
);
return {
targetKey,
canCloseLeft,
canCloseRight,
canCloseOthers,
};
};
// 如果没有标签页,不渲染
if (!tabs || tabs.length === 0) {
return null;
}
return (
<div className={styles['tab-bar']}>
<div className={styles['tab-list']}>
{tabs.map((tab) => {
const contextMenuProps = getContextMenuProps(tab.key);
const isActive = activeKey === tab.key;
return (
<ContextMenu
key={tab.key}
{...contextMenuProps}
onMenuClick={handleContextMenu}
>
<div
className={`${styles['tab-item']} ${
isActive ? styles['tab-item-active'] : ''
}`}
onClick={() => handleTabClick(tab.key)}
>
<span className={styles['tab-title']}>
{locale[tab.name] || tab.name}
</span>
{tab.closable && (
<span
className={styles['tab-close']}
onClick={(e) => handleTabClose(e, tab.key)}
>
<IconClose />
</span>
)}
</div>
</ContextMenu>
);
})}
</div>
</div>
);
};
export default TabBar;