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