From 331da3c8c7b86da2a5057b74cf958292f80c44c9 Mon Sep 17 00:00:00 2001 From: zhongming4762 Date: Wed, 4 Feb 2026 19:29:33 +0800 Subject: [PATCH 1/4] perf: optimize the closing jump logic of tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 依据tab访问历史回退上一个tab,原逻辑是返回一下个 或 上一个 --- .../shared/src/utils/__tests__/stack.test.ts | 107 ++++++++++++++++++ packages/@core/base/shared/src/utils/index.ts | 1 + packages/@core/base/shared/src/utils/stack.ts | 103 +++++++++++++++++ packages/stores/src/modules/tabbar.ts | 50 +++++--- 4 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 packages/@core/base/shared/src/utils/__tests__/stack.test.ts create mode 100644 packages/@core/base/shared/src/utils/stack.ts diff --git a/packages/@core/base/shared/src/utils/__tests__/stack.test.ts b/packages/@core/base/shared/src/utils/__tests__/stack.test.ts new file mode 100644 index 00000000..2803ef24 --- /dev/null +++ b/packages/@core/base/shared/src/utils/__tests__/stack.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createStack, Stack } from '../stack'; + +describe('stack', () => { + let stack: Stack; + + beforeEach(() => { + stack = new Stack(); + }); + + it('push & size should work', () => { + stack.push(1, 2); + + expect(stack.size).toBe(2); + }); + + it('peek should return top element without removing it', () => { + stack.push(1, 2); + + expect(stack.peek()).toBe(2); + expect(stack.size).toBe(2); + }); + + it('pop should remove and return top element', () => { + stack.push(1, 2); + + expect(stack.pop()).toBe(2); + expect(stack.size).toBe(1); + expect(stack.peek()).toBe(1); + }); + + it('pop on empty stack should return undefined', () => { + expect(stack.pop()).toBeUndefined(); + expect(stack.peek()).toBeUndefined(); + }); + + it('clear should remove all elements', () => { + stack.push(1, 2); + + stack.clear(); + + expect(stack.size).toBe(0); + expect(stack.peek()).toBeUndefined(); + }); + + it('toArray should return a shallow copy', () => { + stack.push(1, 2); + + const arr = stack.toArray(); + arr.push(3); + + expect(stack.size).toBe(2); + expect(stack.toArray()).toEqual([1, 2]); + }); + + it('dedup should remove existing item before push', () => { + stack.push(1, 2, 1); + + expect(stack.toArray()).toEqual([2, 1]); + expect(stack.size).toBe(2); + }); + + it('dedup = false should allow duplicate items', () => { + const s = new Stack(false); + + s.push(1, 1, 1); + + expect(s.toArray()).toEqual([1, 1, 1]); + expect(s.size).toBe(3); + }); + + it('remove should delete all matching items', () => { + stack.push(1, 2, 1); + + stack.remove(1); + + expect(stack.toArray()).toEqual([2]); + expect(stack.size).toBe(1); + }); + + it('maxSize should limit stack capacity', () => { + const s = new Stack(true, 3); + + s.push(1, 2, 3, 4); + + expect(s.toArray()).toEqual([2, 3, 4]); + expect(s.size).toBe(3); + }); + + it('dedup + maxSize should work together', () => { + const s = new Stack(true, 3); + + s.push(1, 2, 3, 2); // 去重并重新入栈 + + expect(s.toArray()).toEqual([1, 3, 2]); + expect(s.size).toBe(3); + }); + + it('createStack should create a stack instance', () => { + const s = createStack(true, 2); + + s.push(1, 2, 3); + + expect(s.toArray()).toEqual([2, 3]); + }); +}); diff --git a/packages/@core/base/shared/src/utils/index.ts b/packages/@core/base/shared/src/utils/index.ts index fe8cd289..fb9a4807 100644 --- a/packages/@core/base/shared/src/utils/index.ts +++ b/packages/@core/base/shared/src/utils/index.ts @@ -8,6 +8,7 @@ export * from './letter'; export * from './merge'; export * from './nprogress'; export * from './resources'; +export * from './stack'; export * from './state-handler'; export * from './to'; export * from './tree'; diff --git a/packages/@core/base/shared/src/utils/stack.ts b/packages/@core/base/shared/src/utils/stack.ts new file mode 100644 index 00000000..d8f5d4af --- /dev/null +++ b/packages/@core/base/shared/src/utils/stack.ts @@ -0,0 +1,103 @@ +/** + * @zh_CN 栈数据结构 + */ +export class Stack { + /** + * @zh_CN 栈内元素数量 + */ + get size() { + return this.items.length; + } + /** + * @zh_CN 是否去重 + */ + private readonly dedup: boolean; + /** + * @zh_CN 栈内元素 + */ + private items: T[] = []; + + /** + * @zh_CN 栈的最大容量 + */ + private readonly maxSize?: number; + + constructor(dedup = true, maxSize?: number) { + this.maxSize = maxSize; + this.dedup = dedup; + } + + /** + * @zh_CN 清空栈内元素 + */ + clear() { + this.items.length = 0; + } + + /** + * @zh_CN 查看栈顶元素 + * @returns 栈顶元素 + */ + peek(): T | undefined { + return this.items[this.items.length - 1]; + } + + /** + * @zh_CN 出栈 + * @returns 栈顶元素 + */ + pop(): T | undefined { + return this.items.pop(); + } + + /** + * @zh_CN 入栈 + * @param items 要入栈的元素 + */ + push(...items: T[]) { + items.forEach((item) => { + // 去重 + if (this.dedup) { + const index = this.items.indexOf(item); + if (index !== -1) { + this.items.splice(index, 1); + } + } + this.items.push(item); + if (this.maxSize && this.items.length > this.maxSize) { + this.items.splice(0, this.items.length - this.maxSize); + } + }); + } + /** + * @zh_CN 移除栈内元素 + * @param itemList 要移除的元素列表 + */ + remove(...itemList: T[]) { + this.items = this.items.filter((i) => !itemList.includes(i)); + } + /** + * @zh_CN 保留栈内元素 + * @param itemList 要保留的元素列表 + */ + retain(itemList: T[]) { + this.items = this.items.filter((i) => itemList.includes(i)); + } + + /** + * @zh_CN 转换为数组 + * @returns 栈内元素数组 + */ + toArray(): T[] { + return [...this.items]; + } +} + +/** + * @zh_CN 创建一个栈实例 + * @param dedup 是否去重 + * @param maxSize 栈的最大容量 + * @returns 栈实例 + */ +export const createStack = (dedup = true, maxSize?: number) => + new Stack(dedup, maxSize); diff --git a/packages/stores/src/modules/tabbar.ts b/packages/stores/src/modules/tabbar.ts index b1b4060f..2de11b81 100644 --- a/packages/stores/src/modules/tabbar.ts +++ b/packages/stores/src/modules/tabbar.ts @@ -11,7 +11,9 @@ import { toRaw } from 'vue'; import { preferences } from '@vben-core/preferences'; import { + createStack, openRouteInNewWindow, + Stack, startProgress, stopProgress, } from '@vben-core/shared/utils'; @@ -47,8 +49,17 @@ interface TabbarState { * @zh_CN 更新时间,用于一些更新场景,使用watch深度监听的话,会损耗性能 */ updateTime?: number; + /** + * @zh_CN 上一个标签页打开的标签 + */ + visitHistory: Stack; } +/** + * @zh_CN 访问历史记录最大数量 + */ +const MAX_VISIT_HISTORY = 50; + /** * @zh_CN 访问权限相关 */ @@ -62,6 +73,7 @@ export const useTabbarStore = defineStore('core-tabbar', { this.tabs = this.tabs.filter( (item) => !keySet.has(getTabKeyFromTab(item)), ); + this.visitHistory.remove(...keys); await this.updateCacheTabs(); }, @@ -166,6 +178,8 @@ export const useTabbarStore = defineStore('core-tabbar', { this.tabs.splice(tabIndex, 1, mergedTab); } this.updateCacheTabs(); + // 添加访问历史记录 + this.visitHistory.push(tab.key as string); return tab; }, /** @@ -174,6 +188,8 @@ export const useTabbarStore = defineStore('core-tabbar', { async closeAllTabs(router: Router) { const newTabs = this.tabs.filter((tab) => isAffixTab(tab)); this.tabs = newTabs.length > 0 ? newTabs : [...this.tabs].splice(0, 1); + // 设置访问历史 + this.visitHistory.retain(this.tabs.map((item) => getTabKeyFromTab(item))); await this._goToDefaultTab(router); this.updateCacheTabs(); }, @@ -249,29 +265,26 @@ export const useTabbarStore = defineStore('core-tabbar', { */ async closeTab(tab: TabDefinition, router: Router) { const { currentRoute } = router; + const currentTabKey = getTabKey(currentRoute.value); // 关闭不是激活选项卡 - if (getTabKey(currentRoute.value) !== getTabKeyFromTab(tab)) { + if (currentTabKey !== getTabKeyFromTab(tab)) { this._close(tab); this.updateCacheTabs(); + // 移除访问历史 + this.visitHistory.remove(getTabKeyFromTab(tab)); return; } - const index = this.getTabs.findIndex( - (item) => getTabKeyFromTab(item) === getTabKey(currentRoute.value), - ); - - const before = this.getTabs[index - 1]; - const after = this.getTabs[index + 1]; - - // 下一个tab存在,跳转到下一个 - if (after) { - this._close(tab); - await this._goToTab(after, router); - // 上一个tab存在,跳转到上一个 - } else if (before) { - this._close(tab); - await this._goToTab(before, router); - } else { + if (this.getTabs.length <= 1) { console.error('Failed to close the tab; only one tab remains open.'); + return; + } + // 从访问历史记录中移除当前关闭的tab + this.visitHistory.remove(currentTabKey); + this._close(tab); + const previousTabKey = this.visitHistory.pop(); + if (previousTabKey) { + // 跳转到上一个tab + await this._goToTab(this.getTabByKey(previousTabKey), router); } }, @@ -527,11 +540,12 @@ export const useTabbarStore = defineStore('core-tabbar', { persist: [ // tabs不需要保存在localStorage { - pick: ['tabs'], + pick: ['tabs', 'visitHistory'], storage: sessionStorage, }, ], state: (): TabbarState => ({ + visitHistory: createStack(true, MAX_VISIT_HISTORY), cachedTabs: new Set(), dragEndIndex: 0, excludeCachedTabs: new Set(), From 7a2b9163879dc8fcd832f0079451fa49324caf53 Mon Sep 17 00:00:00 2001 From: zhongming4762 Date: Sun, 8 Feb 2026 20:36:16 +0800 Subject: [PATCH 2/4] perf: optimize the closing jump logic of tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 依据tab访问历史回退上一个tab,原逻辑是返回一下个 或 上一个 * 支持在配置中开启或关闭 --- packages/@core/preferences/src/config.ts | 1 + packages/@core/preferences/src/types.ts | 2 + .../preferences/blocks/layout/tabbar.vue | 4 ++ .../preferences/preferences-drawer.vue | 2 + .../locales/src/langs/en-US/preferences.json | 2 + .../locales/src/langs/zh-CN/preferences.json | 2 + packages/stores/src/modules/tabbar.ts | 72 +++++++++++++++---- 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/packages/@core/preferences/src/config.ts b/packages/@core/preferences/src/config.ts index d9977b1e..f788e895 100644 --- a/packages/@core/preferences/src/config.ts +++ b/packages/@core/preferences/src/config.ts @@ -106,6 +106,7 @@ const defaultPreferences: Preferences = { showMaximize: true, showMore: true, styleType: 'chrome', + visitHistory: true, wheelable: true, }, theme: { diff --git a/packages/@core/preferences/src/types.ts b/packages/@core/preferences/src/types.ts index 17224b04..e1ef0a38 100644 --- a/packages/@core/preferences/src/types.ts +++ b/packages/@core/preferences/src/types.ts @@ -224,6 +224,8 @@ interface TabbarPreferences { showMore: boolean; /** 标签页风格 */ styleType: TabsStyleType; + /** 是否开启访问历史记录 */ + visitHistory: boolean; /** 是否开启鼠标滚轮响应 */ wheelable: boolean; } diff --git a/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue b/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue index ea533dad..813f6041 100644 --- a/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue +++ b/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue @@ -18,6 +18,7 @@ defineProps<{ disabled?: boolean }>(); const tabbarEnable = defineModel('tabbarEnable'); const tabbarShowIcon = defineModel('tabbarShowIcon'); const tabbarPersist = defineModel('tabbarPersist'); +const tabbarVisitHistory = defineModel('tabbarVisitHistory'); const tabbarDraggable = defineModel('tabbarDraggable'); const tabbarWheelable = defineModel('tabbarWheelable'); const tabbarStyleType = defineModel('tabbarStyleType'); @@ -56,6 +57,9 @@ const styleItems = computed((): SelectOption[] => [ {{ $t('preferences.tabbar.persist') }} + + {{ $t('preferences.tabbar.visitHistory') }} + ('tabbarShowIcon'); const tabbarShowMore = defineModel('tabbarShowMore'); const tabbarShowMaximize = defineModel('tabbarShowMaximize'); const tabbarPersist = defineModel('tabbarPersist'); +const tabbarVisitHistory = defineModel('tabbarVisitHistory'); const tabbarDraggable = defineModel('tabbarDraggable'); const tabbarWheelable = defineModel('tabbarWheelable'); const tabbarStyleType = defineModel('tabbarStyleType'); @@ -400,6 +401,7 @@ async function handleReset() { v-model:tabbar-draggable="tabbarDraggable" v-model:tabbar-enable="tabbarEnable" v-model:tabbar-persist="tabbarPersist" + v-model:tabbar-visit-history="tabbarVisitHistory" v-model:tabbar-show-icon="tabbarShowIcon" v-model:tabbar-show-maximize="tabbarShowMaximize" v-model:tabbar-show-more="tabbarShowMore" diff --git a/packages/locales/src/langs/en-US/preferences.json b/packages/locales/src/langs/en-US/preferences.json index 977632ac..f32d0b6a 100644 --- a/packages/locales/src/langs/en-US/preferences.json +++ b/packages/locales/src/langs/en-US/preferences.json @@ -68,6 +68,8 @@ "showMore": "Show More Button", "showMaximize": "Show Maximize Button", "persist": "Persist Tabs", + "visitHistory": "Visit History", + "visitHistoryTip": "When enabled, the tab bar records tab visit history. \nClosing the current tab will automatically select the last opened tab.", "maxCount": "Max Count of Tabs", "maxCountTip": "When the number of tabs exceeds the maximum,\nthe oldest tab will be closed.\n Set to 0 to disable count checking.", "draggable": "Enable Draggable Sort", diff --git a/packages/locales/src/langs/zh-CN/preferences.json b/packages/locales/src/langs/zh-CN/preferences.json index f8e390f2..a36a9dbb 100644 --- a/packages/locales/src/langs/zh-CN/preferences.json +++ b/packages/locales/src/langs/zh-CN/preferences.json @@ -68,6 +68,8 @@ "showMore": "显示更多按钮", "showMaximize": "显示最大化按钮", "persist": "持久化标签页", + "visitHistory": "访问历史记录", + "visitHistoryTip": "开启后,标签栏会记录标签访问历史\n关闭当前标签,会自动选中上一个打开的标签", "maxCount": "最大标签数", "maxCountTip": "每次打开新的标签时如果超过最大标签数,\n会自动关闭一个最先打开的标签\n设置为 0 则不限制", "draggable": "启动拖拽排序", diff --git a/packages/stores/src/modules/tabbar.ts b/packages/stores/src/modules/tabbar.ts index 2de11b81..6b18c26e 100644 --- a/packages/stores/src/modules/tabbar.ts +++ b/packages/stores/src/modules/tabbar.ts @@ -73,7 +73,9 @@ export const useTabbarStore = defineStore('core-tabbar', { this.tabs = this.tabs.filter( (item) => !keySet.has(getTabKeyFromTab(item)), ); - this.visitHistory.remove(...keys); + if (isVisitHistory()) { + this.visitHistory.remove(...keys); + } await this.updateCacheTabs(); }, @@ -179,7 +181,9 @@ export const useTabbarStore = defineStore('core-tabbar', { } this.updateCacheTabs(); // 添加访问历史记录 - this.visitHistory.push(tab.key as string); + if (isVisitHistory()) { + this.visitHistory.push(tab.key as string); + } return tab; }, /** @@ -188,8 +192,12 @@ export const useTabbarStore = defineStore('core-tabbar', { async closeAllTabs(router: Router) { const newTabs = this.tabs.filter((tab) => isAffixTab(tab)); this.tabs = newTabs.length > 0 ? newTabs : [...this.tabs].splice(0, 1); - // 设置访问历史 - this.visitHistory.retain(this.tabs.map((item) => getTabKeyFromTab(item))); + // 设置访问历史记录 + if (isVisitHistory()) { + this.visitHistory.retain( + this.tabs.map((item) => getTabKeyFromTab(item)), + ); + } await this._goToDefaultTab(router); this.updateCacheTabs(); }, @@ -270,8 +278,10 @@ export const useTabbarStore = defineStore('core-tabbar', { if (currentTabKey !== getTabKeyFromTab(tab)) { this._close(tab); this.updateCacheTabs(); - // 移除访问历史 - this.visitHistory.remove(getTabKeyFromTab(tab)); + // 移除访问历史记录 + if (isVisitHistory()) { + this.visitHistory.remove(getTabKeyFromTab(tab)); + } return; } if (this.getTabs.length <= 1) { @@ -279,12 +289,43 @@ export const useTabbarStore = defineStore('core-tabbar', { return; } // 从访问历史记录中移除当前关闭的tab - this.visitHistory.remove(currentTabKey); - this._close(tab); - const previousTabKey = this.visitHistory.pop(); - if (previousTabKey) { - // 跳转到上一个tab - await this._goToTab(this.getTabByKey(previousTabKey), router); + if (isVisitHistory()) { + this.visitHistory.remove(currentTabKey); + this._close(tab); + + let previousTab: TabDefinition | undefined; + let previousTabKey: string | undefined; + while (true) { + previousTabKey = this.visitHistory.pop(); + if (!previousTabKey) { + break; + } + previousTab = this.getTabByKey(previousTabKey); + if (previousTab) { + break; + } + } + await (previousTab + ? this._goToTab(previousTab, router) + : this._goToDefaultTab(router)); + return; + } + // 未开启访问历史记录,直接跳转下一个或上一个tab + const index = this.getTabs.findIndex( + (item) => getTabKeyFromTab(item) === getTabKey(currentRoute.value), + ); + + const before = this.getTabs[index - 1]; + const after = this.getTabs[index + 1]; + + // 下一个tab存在,跳转到下一个 + if (after) { + this._close(tab); + await this._goToTab(after, router); + // 上一个tab存在,跳转到上一个 + } else if (before) { + this._close(tab); + await this._goToTab(before, router); } }, @@ -642,6 +683,13 @@ function getTabKey(tab: RouteLocationNormalized | RouteRecordNormalized) { } } +/** + * @zh_CN 是否开启访问历史记录 + */ +function isVisitHistory() { + return preferences.tabbar.visitHistory; +} + /** * 从tab获取tab页的key * 如果tab没有key,那么就从route获取key From a8431e204087b74677b0371cf704372c3e4ac7de Mon Sep 17 00:00:00 2001 From: zhongming4762 Date: Sun, 8 Feb 2026 20:36:32 +0800 Subject: [PATCH 3/4] perf: optimize the closing jump logic of tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 依据tab访问历史回退上一个tab,原逻辑是返回一下个 或 上一个 * 支持在配置中开启或关闭 --- .../src/widgets/preferences/blocks/layout/tabbar.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue b/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue index 813f6041..885c30c3 100644 --- a/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue +++ b/packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue @@ -57,7 +57,11 @@ const styleItems = computed((): SelectOption[] => [ {{ $t('preferences.tabbar.persist') }} - + {{ $t('preferences.tabbar.visitHistory') }} Date: Sun, 8 Feb 2026 20:50:54 +0800 Subject: [PATCH 4/4] perf: optimize the closing jump logic of tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 依据tab访问历史回退上一个tab,原逻辑是返回一下个 或 上一个 * 支持在配置中开启或关闭 --- .../preferences/__tests__/__snapshots__/config.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap b/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap index a3c0c317..86eff1b9 100644 --- a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap @@ -105,6 +105,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj "showMaximize": true, "showMore": true, "styleType": "chrome", + "visitHistory": true, "wheelable": true, }, "theme": {