feat: 页面标签组件样式与功能优化

This commit is contained in:
ZhuRui
2026-07-30 18:02:41 +08:00
parent 7606bb7d8d
commit ea906a0d92
4 changed files with 208 additions and 4 deletions
+78 -1
View File
@@ -1,14 +1,24 @@
import {
defineComponent,
h,
nextTick,
onBeforeUnmount,
onMounted,
ref,
type PropType,
type VNode,
watch,
} from 'vue';
import { message } from 'ant-design-vue';
import { Dropdown, Menu, message } from 'ant-design-vue';
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
import type { IconType } from 'vue-icons-plus/lib';
import {
LuCopyX,
LuListX,
LuMoreHorizontal,
LuPanelLeftClose,
LuPanelRightClose,
} from 'vue-icons-plus/lu';
import type { TabView } from '@/types';
import { useState } from '@/hooks';
import { renderRouteIcon } from '@/utils/routeIcon';
@@ -17,6 +27,10 @@ import styles from './style.module.less';
const SCROLL_STEP = 240;
type TabKeyHandler = (key: string) => void;
type TabBatchHandler = (key: string) => void;
type TabAllHandler = () => void;
const renderAiIcon = (Icon: IconType, size = 16) => h(Icon, { size });
/**
* Chrome 风格的页面标签栏组件
@@ -32,6 +46,14 @@ const PageTabs = defineComponent({
activeKey: { type: String, required: true },
/** 关闭标签 */
onClose: { type: Function as PropType<TabKeyHandler>, default: undefined },
/** 关闭当前标签左侧标签 */
onCloseLeft: { type: Function as PropType<TabBatchHandler>, default: undefined },
/** 关闭当前标签右侧标签 */
onCloseRight: { type: Function as PropType<TabBatchHandler>, default: undefined },
/** 关闭当前标签以外的标签 */
onCloseOther: { type: Function as PropType<TabBatchHandler>, default: undefined },
/** 关闭全部标签 */
onCloseAll: { type: Function as PropType<TabAllHandler>, default: undefined },
/** 切换标签 */
onChange: { type: Function as PropType<TabKeyHandler>, default: undefined },
},
@@ -116,6 +138,34 @@ const PageTabs = defineComponent({
}
};
const handleBatchClose = (key: string) => {
if (props.tabs.length <= 1) {
message.warning('至少保留一个标签');
return;
}
const activeIndex = props.tabs.findIndex((tab) => tab.path === props.activeKey);
if (activeIndex === -1) return;
if (key === 'left') {
if (activeIndex === 0) {
message.warning('左侧没有可关闭的标签页');
return;
}
props.onCloseLeft?.(props.activeKey);
} else if (key === 'right') {
if (activeIndex === props.tabs.length - 1) {
message.warning('右侧没有可关闭的标签页');
return;
}
props.onCloseRight?.(props.activeKey);
} else if (key === 'other') {
props.onCloseOther?.(props.activeKey);
} else if (key === 'all') {
props.onCloseAll?.();
}
};
onMounted(() => {
const el = scrollRef.value;
if (!el) return;
@@ -174,6 +224,15 @@ const PageTabs = defineComponent({
);
};
const renderBatchMenuItem = (key: string, icon: VNode, label: string) => (
<Menu.Item key={key}>
<div class={styles.batchMenuItem}>
<span class={styles.batchMenuIcon}>{icon}</span>
<span>{label}</span>
</div>
</Menu.Item>
);
return () => (
<div class={styles.pageTabsWrap}>
{showNavLeft.value && (
@@ -201,6 +260,24 @@ const PageTabs = defineComponent({
<RightOutlined />
</button>
)}
<Dropdown trigger={['click']} placement="bottomRight">
{{
default: () => (
<button type="button" class={styles.batchBtn} aria-label="标签页操作菜单">
{renderAiIcon(LuMoreHorizontal)}
</button>
),
overlay: () => (
<Menu onClick={({ key }) => handleBatchClose(String(key))}>
{renderBatchMenuItem('left', renderAiIcon(LuPanelLeftClose), '关闭左侧标签页')}
{renderBatchMenuItem('right', renderAiIcon(LuPanelRightClose), '关闭右侧标签页')}
{renderBatchMenuItem('other', renderAiIcon(LuCopyX), '关闭其它标签页')}
{renderBatchMenuItem('all', renderAiIcon(LuListX), '关闭全部标签页')}
</Menu>
),
}}
</Dropdown>
</div>
);
},
+63 -2
View File
@@ -2,6 +2,8 @@
.pageTabsWrap {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 100%;
@@ -42,13 +44,46 @@
}
.navBtnRight {
right: 0;
right: 34px;
}
.batchBtn {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-left: 6px;
padding: 0;
border: none;
border-radius: 6px;
background: #fff;
color: rgba(0, 0, 0, 0.45);
box-shadow: 0 0 4px rgba(214, 214, 214, 0.9);
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s;
&:hover {
color: rgba(0, 0, 0, 0.88);
background: #f5f5f5;
}
&:active {
color: rgba(0, 0, 0, 0.88);
background: #ebebeb;
}
}
.pageTabs {
display: flex;
flex: 1;
align-items: flex-start;
width: 100%;
box-sizing: border-box;
min-width: 0;
width: auto;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
@@ -227,3 +262,29 @@
}
}
}
.batchMenuItem {
display: flex;
align-items: center;
padding: 4px 0;
gap: 8px;
line-height: 1;
}
.batchMenuItem + .batchMenuItem {
margin-top: 4px;
}
.batchMenuIcon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
line-height: 1;
> svg {
display: block;
}
}
+29 -1
View File
@@ -79,7 +79,15 @@ export default defineComponent({
const { menuItems } = useMenuStore();
const { phone } = useUserStore();
const { tabs, cachedViews, removeTab } = useTabsStore();
const {
tabs,
cachedViews,
removeTab,
removeLeftTabs,
removeRightTabs,
removeOtherTabs,
removeAllTabs,
} = useTabsStore();
const { breadcrumbs } = useBreadcrumb();
const selectedKeys = computed<string[]>(() => [route.path]);
@@ -160,6 +168,22 @@ export default defineComponent({
removeTab(key);
};
const handleCloseLeftTabs = (key: string) => {
removeLeftTabs(key);
};
const handleCloseRightTabs = (key: string) => {
removeRightTabs(key);
};
const handleCloseOtherTabs = (key: string) => {
removeOtherTabs(key);
};
const handleCloseAllTabs = () => {
removeAllTabs();
};
return () => (
<Layout class={styles.basicLayoutMain}>
<Sider style={siderStyle} collapsed={collapsed.value} trigger={null} collapsible>
@@ -241,6 +265,10 @@ export default defineComponent({
activeKey={activeTabKey.value}
onChange={handleTabChange}
onClose={handleTabClose}
onCloseLeft={handleCloseLeftTabs}
onCloseRight={handleCloseRightTabs}
onCloseOther={handleCloseOtherTabs}
onCloseAll={handleCloseAllTabs}
/>
</div>
</Header>
+38
View File
@@ -69,10 +69,46 @@ export function useTabsStore() {
}
};
/** 关闭 path 左侧的所有 tab */
const removeLeftTabs = (path: string): void => {
const idx = state.tabs.findIndex((t) => t.path === path);
if (idx <= 0) return;
const currentPath = router.currentRoute.value.path;
const shouldRedirect = state.tabs.slice(0, idx).some((t) => t.path === currentPath);
state.tabs = state.tabs.slice(idx);
syncCachedViews();
if (shouldRedirect) {
router.push(path);
}
};
/** 关闭 path 右侧的所有 tab */
const removeRightTabs = (path: string): void => {
const idx = state.tabs.findIndex((t) => t.path === path);
if (idx === -1 || idx >= state.tabs.length - 1) return;
const currentPath = router.currentRoute.value.path;
const shouldRedirect = state.tabs.slice(idx + 1).some((t) => t.path === currentPath);
state.tabs = state.tabs.slice(0, idx + 1);
syncCachedViews();
if (shouldRedirect) {
router.push(path);
}
};
/** 关闭除 path 之外的所有 tab */
const removeOtherTabs = (path: string): void => {
const currentPath = router.currentRoute.value.path;
state.tabs = state.tabs.filter((t) => t.path === path);
syncCachedViews();
if (currentPath !== path && state.tabs.length > 0) {
router.push(path);
}
};
/** 关闭所有 tab,保留第一个并跳转过去 */
@@ -106,6 +142,8 @@ export function useTabsStore() {
cachedViews: computed(() => state.cachedViews),
addTab,
removeTab,
removeLeftTabs,
removeRightTabs,
removeOtherTabs,
removeAllTabs,
updateTab,