feat(标签页): 实现多标签页组件功能
- 新增 TabBar 主组件,支持标签页展示和交互 - 新增 ContextMenu 右键菜单组件 - 实现圆角胶囊样式设计,渐变色激活态 - 支持点击标签切换路由 - 支持关闭标签功能,首页标签不可关闭 - 支持右键菜单:重新加载、关闭左侧/右侧/其他标签 - 添加流畅的过渡动画和 hover 效果
This commit is contained in:
parent
e8cacc2c7a
commit
2d63d8d5e4
71
src/components/TabBar/ContextMenu.tsx
Normal file
71
src/components/TabBar/ContextMenu.tsx
Normal 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;
|
||||||
105
src/components/TabBar/index.module.less
Normal file
105
src/components/TabBar/index.module.less
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/components/TabBar/index.tsx
Normal file
178
src/components/TabBar/index.tsx
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user