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/@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": { 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..885c30c3 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,13 @@ 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 b1b4060f..6b18c26e 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,9 @@ export const useTabbarStore = defineStore('core-tabbar', { this.tabs = this.tabs.filter( (item) => !keySet.has(getTabKeyFromTab(item)), ); + if (isVisitHistory()) { + this.visitHistory.remove(...keys); + } await this.updateCacheTabs(); }, @@ -166,6 +180,10 @@ export const useTabbarStore = defineStore('core-tabbar', { this.tabs.splice(tabIndex, 1, mergedTab); } this.updateCacheTabs(); + // 添加访问历史记录 + if (isVisitHistory()) { + this.visitHistory.push(tab.key as string); + } return tab; }, /** @@ -174,6 +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); + // 设置访问历史记录 + if (isVisitHistory()) { + this.visitHistory.retain( + this.tabs.map((item) => getTabKeyFromTab(item)), + ); + } await this._goToDefaultTab(router); this.updateCacheTabs(); }, @@ -249,12 +273,44 @@ 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(); + // 移除访问历史记录 + if (isVisitHistory()) { + this.visitHistory.remove(getTabKeyFromTab(tab)); + } return; } + if (this.getTabs.length <= 1) { + console.error('Failed to close the tab; only one tab remains open.'); + return; + } + // 从访问历史记录中移除当前关闭的tab + 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), ); @@ -270,8 +326,6 @@ export const useTabbarStore = defineStore('core-tabbar', { } else if (before) { this._close(tab); await this._goToTab(before, router); - } else { - console.error('Failed to close the tab; only one tab remains open.'); } }, @@ -527,11 +581,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(), @@ -628,6 +683,13 @@ function getTabKey(tab: RouteLocationNormalized | RouteRecordNormalized) { } } +/** + * @zh_CN 是否开启访问历史记录 + */ +function isVisitHistory() { + return preferences.tabbar.visitHistory; +} + /** * 从tab获取tab页的key * 如果tab没有key,那么就从route获取key