Ver código fonte

Merge branch 'tab-2026020401' of https://github.com/ming4762/smart-boot-ui-vben into ming4762-tab-2026020401

Jin Mao 4 meses atrás
pai
commit
57911d9e09

+ 107 - 0
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<number>;
+
+  beforeEach(() => {
+    stack = new Stack<number>();
+  });
+
+  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<number>(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<number>(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<number>(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<number>(true, 2);
+
+    s.push(1, 2, 3);
+
+    expect(s.toArray()).toEqual([2, 3]);
+  });
+});

+ 1 - 0
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';

+ 103 - 0
packages/@core/base/shared/src/utils/stack.ts

@@ -0,0 +1,103 @@
+/**
+ * @zh_CN 栈数据结构
+ */
+export class Stack<T> {
+  /**
+   * @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 = <T>(dedup = true, maxSize?: number) =>
+  new Stack<T>(dedup, maxSize);

+ 1 - 0
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": {

+ 1 - 0
packages/@core/preferences/src/config.ts

@@ -106,6 +106,7 @@ const defaultPreferences: Preferences = {
     showMaximize: true,
     showMore: true,
     styleType: 'chrome',
+    visitHistory: true,
     wheelable: true,
   },
   theme: {

+ 2 - 0
packages/@core/preferences/src/types.ts

@@ -224,6 +224,8 @@ interface TabbarPreferences {
   showMore: boolean;
   /** 标签页风格 */
   styleType: TabsStyleType;
+  /** 是否开启访问历史记录 */
+  visitHistory: boolean;
   /** 是否开启鼠标滚轮响应 */
   wheelable: boolean;
 }

+ 8 - 0
packages/effects/layouts/src/widgets/preferences/blocks/layout/tabbar.vue

@@ -18,6 +18,7 @@ defineProps<{ disabled?: boolean }>();
 const tabbarEnable = defineModel<boolean>('tabbarEnable');
 const tabbarShowIcon = defineModel<boolean>('tabbarShowIcon');
 const tabbarPersist = defineModel<boolean>('tabbarPersist');
+const tabbarVisitHistory = defineModel<boolean>('tabbarVisitHistory');
 const tabbarDraggable = defineModel<boolean>('tabbarDraggable');
 const tabbarWheelable = defineModel<boolean>('tabbarWheelable');
 const tabbarStyleType = defineModel<string>('tabbarStyleType');
@@ -56,6 +57,13 @@ const styleItems = computed((): SelectOption[] => [
   <SwitchItem v-model="tabbarPersist" :disabled="!tabbarEnable">
     {{ $t('preferences.tabbar.persist') }}
   </SwitchItem>
+  <SwitchItem
+    v-model="tabbarVisitHistory"
+    :disabled="!tabbarEnable"
+    :tip="$t('preferences.tabbar.visitHistoryTip')"
+  >
+    {{ $t('preferences.tabbar.visitHistory') }}
+  </SwitchItem>
   <NumberFieldItem
     v-model="tabbarMaxCount"
     :disabled="!tabbarEnable"

+ 2 - 0
packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue

@@ -120,6 +120,7 @@ const tabbarShowIcon = defineModel<boolean>('tabbarShowIcon');
 const tabbarShowMore = defineModel<boolean>('tabbarShowMore');
 const tabbarShowMaximize = defineModel<boolean>('tabbarShowMaximize');
 const tabbarPersist = defineModel<boolean>('tabbarPersist');
+const tabbarVisitHistory = defineModel<boolean>('tabbarVisitHistory');
 const tabbarDraggable = defineModel<boolean>('tabbarDraggable');
 const tabbarWheelable = defineModel<boolean>('tabbarWheelable');
 const tabbarStyleType = defineModel<string>('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"

+ 2 - 0
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",

+ 2 - 0
packages/locales/src/langs/zh-CN/preferences.json

@@ -68,6 +68,8 @@
     "showMore": "显示更多按钮",
     "showMaximize": "显示最大化按钮",
     "persist": "持久化标签页",
+    "visitHistory": "访问历史记录",
+    "visitHistoryTip": "开启后,标签栏会记录标签访问历史\n关闭当前标签,会自动选中上一个打开的标签",
     "maxCount": "最大标签数",
     "maxCountTip": "每次打开新的标签时如果超过最大标签数,\n会自动关闭一个最先打开的标签\n设置为 0 则不限制",
     "draggable": "启动拖拽排序",

+ 66 - 4
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<string>;
 }
 
+/**
+ * @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<string>(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