Browse Source

refactor(@vben-core/tabs-ui): refactor tabs chrome component

vben 9 months ago
parent
commit
fed422e187
35 changed files with 662 additions and 518 deletions
  1. 6 0
      .gitignore
  2. 3 1
      cspell.json
  3. 20 4
      packages/@core/forward/stores/src/modules/tabbar.ts
  4. 3 3
      packages/@core/locales/src/langs/en-US.json
  5. 3 3
      packages/@core/locales/src/langs/zh-CN.json
  6. 7 0
      packages/@core/shared/hooks/build.config.ts
  7. 45 0
      packages/@core/shared/hooks/package.json
  8. 1 0
      packages/@core/shared/hooks/src/index.ts
  9. 46 0
      packages/@core/shared/hooks/src/use-sortable.test.ts
  10. 33 0
      packages/@core/shared/hooks/src/use-sortable.ts
  11. 6 0
      packages/@core/shared/hooks/tsconfig.json
  12. 1 1
      packages/@core/ui-kit/layout-ui/src/components/layout-tabbar.vue
  13. 0 3
      packages/@core/ui-kit/layout-ui/src/vben-layout.vue
  14. 21 0
      packages/@core/ui-kit/tabs-ui/build.config.ts
  15. 2 2
      packages/@core/ui-kit/tabs-ui/package.json
  16. 0 193
      packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/chrome-tabs.scss
  17. 0 35
      packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/tab-background.vue
  18. 0 76
      packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/tab.vue
  19. 0 114
      packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/tabs.vue
  20. 1 1
      packages/@core/ui-kit/tabs-ui/src/components/index.ts
  21. 264 0
      packages/@core/ui-kit/tabs-ui/src/components/tabs-chrome/tabs.vue
  22. 0 0
      packages/@core/ui-kit/tabs-ui/src/components/widgets/index.ts
  23. 1 1
      packages/@core/ui-kit/tabs-ui/src/components/widgets/tool-more.vue
  24. 1 1
      packages/@core/ui-kit/tabs-ui/src/components/widgets/tool-screen.vue
  25. 1 1
      packages/@core/ui-kit/tabs-ui/src/index.ts
  26. 80 4
      packages/@core/ui-kit/tabs-ui/src/tabs-view.vue
  27. 36 1
      packages/@core/ui-kit/tabs-ui/src/types.ts
  28. 0 3
      packages/@core/ui-kit/tabs-ui/vite.config.mts
  29. 1 4
      packages/effects/layouts/src/basic/layout.vue
  30. 0 1
      packages/effects/layouts/src/basic/tabbar/index.ts
  31. 0 28
      packages/effects/layouts/src/basic/tabbar/tabbar-tools.vue
  32. 20 6
      packages/effects/layouts/src/basic/tabbar/tabbar.vue
  33. 24 23
      packages/effects/layouts/src/basic/tabbar/use-tabs.ts
  34. 32 9
      pnpm-lock.yaml
  35. 4 0
      vben-admin.code-workspace

+ 6 - 0
.gitignore

@@ -2,6 +2,12 @@ node_modules
 .DS_Store
 dist
 dist-ssr
+dist.zip
+dist.tar
+dist.war
+*-dist.zip
+*-dist.tar
+*-dist.war
 coverage
 *.local
 **/.vitepress/cache

+ 3 - 1
cspell.json

@@ -6,6 +6,7 @@
   "words": [
     "clsx",
     "esno",
+    "demi",
     "unref",
     "taze",
     "acmr",
@@ -40,7 +41,8 @@
     "vitepress",
     "ependencies",
     "vite",
-    "echarts"
+    "echarts",
+    "sortablejs"
   ],
   "ignorePaths": [
     "**/node_modules/**",

+ 20 - 4
packages/@core/forward/stores/src/modules/tabbar.ts

@@ -12,6 +12,10 @@ interface TabsState {
    * @zh_CN 当前打开的标签页列表缓存
    */
   cachedTabs: Set<string>;
+  /**
+   * @zh_CN 拖拽结束的索引
+   */
+  dragEndIndex: number;
   /**
    * @zh_CN 需要排除缓存的标签页
    */
@@ -131,7 +135,6 @@ const useCoreTabbarStore = defineStore('core-tabbar', {
       }
       await this._bulkCloseByPaths(paths);
     },
-
     /**
      * @zh_CN 关闭其他标签页
      * @param tab
@@ -210,6 +213,7 @@ const useCoreTabbarStore = defineStore('core-tabbar', {
         console.error('Failed to close the tab; only one tab remains open.');
       }
     },
+
     /**
      * @zh_CN 通过key关闭标签页
      * @param key
@@ -222,7 +226,6 @@ const useCoreTabbarStore = defineStore('core-tabbar', {
 
       await this.closeTab(this.tabs[index], router);
     },
-
     /**
      * @zh_CN 固定标签页
      * @param tab
@@ -236,6 +239,7 @@ const useCoreTabbarStore = defineStore('core-tabbar', {
         this.addTab(tab);
       }
     },
+
     /**
      * 刷新标签页
      */
@@ -263,6 +267,17 @@ const useCoreTabbarStore = defineStore('core-tabbar', {
         this.addTab(routeToTab(tab));
       }
     },
+    /**
+     * @zh_CN 设置标签页顺序
+     * @param oldIndex
+     * @param newIndex
+     */
+    async sortTabs(oldIndex: number, newIndex: number) {
+      const currentTab = this.tabs[oldIndex];
+      this.tabs.splice(oldIndex, 1);
+      this.tabs.splice(newIndex, 0, currentTab);
+      this.dragEndIndex = this.dragEndIndex + 1;
+    },
     /**
      * @zh_CN 取消固定标签页
      * @param tab
@@ -315,7 +330,7 @@ const useCoreTabbarStore = defineStore('core-tabbar', {
     getTabs(): TabItem[] {
       const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
       const normalTabs = this.tabs.filter((tab) => !isAffixTab(tab));
-      return [...affixTabs, ...normalTabs];
+      return [...affixTabs, ...normalTabs].filter(Boolean);
     },
   },
   persist: [
@@ -327,6 +342,7 @@ const useCoreTabbarStore = defineStore('core-tabbar', {
   ],
   state: (): TabsState => ({
     cachedTabs: new Set(),
+    dragEndIndex: 0,
     excludeCachedTabs: new Set(),
     renderRouteView: true,
     tabs: [],
@@ -365,7 +381,7 @@ function cloneTab(route: TabItem): TabItem {
  * @param tab
  */
 function isAffixTab(tab: TabItem) {
-  return tab.meta?.affixTab ?? false;
+  return tab?.meta?.affixTab ?? false;
 }
 
 /**

+ 3 - 3
packages/@core/locales/src/langs/en-US.json

@@ -178,9 +178,9 @@
       "persist": "Persist Tabs",
       "contextMenu": {
         "reload": "Reload",
-        "close": "Close Tab",
-        "pin": "Pin Tab",
-        "unpin": "Unpin Tab",
+        "close": "Close",
+        "pin": "Pin",
+        "unpin": "Unpin",
         "closeLeft": "Close Left Tabs",
         "closeRight": "Close Right Tabs",
         "closeOther": "Close Other Tabs",

+ 3 - 3
packages/@core/locales/src/langs/zh-CN.json

@@ -178,9 +178,9 @@
       "persist": "持久化标签页",
       "contextMenu": {
         "reload": "重新加载",
-        "close": "关闭标签页",
-        "pin": "固定标签页",
-        "unpin": "取消固定标签页",
+        "close": "关闭",
+        "pin": "固定",
+        "unpin": "取消固定",
         "closeLeft": "关闭左侧标签页",
         "closeRight": "关闭右侧标签页",
         "closeOther": "关闭其它标签页",

+ 7 - 0
packages/@core/shared/hooks/build.config.ts

@@ -0,0 +1,7 @@
+import { defineBuildConfig } from 'unbuild';
+
+export default defineBuildConfig({
+  clean: true,
+  declaration: true,
+  entries: ['src/index'],
+});

+ 45 - 0
packages/@core/shared/hooks/package.json

@@ -0,0 +1,45 @@
+{
+  "name": "@vben-core/hooks",
+  "version": "5.0.0",
+  "homepage": "https://github.com/vbenjs/vue-vben-admin",
+  "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+    "directory": "packages/@vben-core/shared/hooks"
+  },
+  "license": "MIT",
+  "type": "module",
+  "scripts": {
+    "build": "pnpm unbuild",
+    "stub": "pnpm unbuild --stub"
+  },
+  "files": [
+    "dist"
+  ],
+  "sideEffects": false,
+  "main": "./dist/index.mjs",
+  "module": "./dist/index.mjs",
+  "exports": {
+    ".": {
+      "types": "./src/index.ts",
+      "development": "./src/index.ts",
+      "default": "./dist/index.mjs"
+    }
+  },
+  "publishConfig": {
+    "exports": {
+      ".": {
+        "types": "./dist/index.d.ts",
+        "default": "./dist/index.mjs"
+      }
+    }
+  },
+  "dependencies": {
+    "sortablejs": "^1.15.2",
+    "vue": "^3.4.31"
+  },
+  "devDependencies": {
+    "@types/sortablejs": "^1.15.8"
+  }
+}

+ 1 - 0
packages/@core/shared/hooks/src/index.ts

@@ -0,0 +1 @@
+export * from './use-sortable';

+ 46 - 0
packages/@core/shared/hooks/src/use-sortable.test.ts

@@ -0,0 +1,46 @@
+import type { SortableOptions } from 'sortablejs';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useSortable } from './use-sortable';
+
+describe('useSortable', () => {
+  beforeEach(() => {
+    vi.mock('sortablejs', () => ({
+      default: {
+        create: vi.fn(),
+      },
+    }));
+  });
+  it('should call Sortable.create with the correct options', async () => {
+    // Create a mock element
+    const mockElement = document.createElement('div') as HTMLDivElement;
+
+    // Define custom options
+    const customOptions: SortableOptions = {
+      group: 'test-group',
+      sort: false,
+    };
+
+    // Use the useSortable function
+    const { initializeSortable } = useSortable(mockElement, customOptions);
+
+    // Initialize sortable
+    await initializeSortable();
+
+    // Import sortablejs to access the mocked create function
+    const Sortable = await import('sortablejs');
+
+    // Verify that Sortable.create was called with the correct parameters
+    expect(Sortable.default.create).toHaveBeenCalledTimes(1);
+    expect(Sortable.default.create).toHaveBeenCalledWith(
+      mockElement,
+      expect.objectContaining({
+        animation: 100,
+        delay: 400,
+        delayOnTouchOnly: true,
+        ...customOptions,
+      }),
+    );
+  });
+});

+ 33 - 0
packages/@core/shared/hooks/src/use-sortable.ts

@@ -0,0 +1,33 @@
+import type { SortableOptions } from 'sortablejs';
+
+function useSortable<T extends HTMLElement>(
+  sortableContainer: T,
+  options: SortableOptions = {},
+) {
+  const initializeSortable = async () => {
+    const Sortable = await import(
+      // @ts-expect-error - This is a dynamic import
+      'sortablejs/modular/sortable.complete.esm.js'
+    );
+    // const { AutoScroll } = await import(
+    //   // @ts-expect-error - This is a dynamic import
+    //   'sortablejs/modular/sortable.core.esm.js'
+    // );
+
+    // Sortable?.default?.mount?.(AutoScroll);
+
+    const sortable = Sortable?.default?.create?.(sortableContainer, {
+      animation: 100,
+      delay: 400,
+      delayOnTouchOnly: true,
+      ...options,
+    });
+    return sortable;
+  };
+
+  return {
+    initializeSortable,
+  };
+}
+
+export { useSortable };

+ 6 - 0
packages/@core/shared/hooks/tsconfig.json

@@ -0,0 +1,6 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@vben/tsconfig/library.json",
+  "include": ["src"],
+  "exclude": ["node_modules"]
+}

+ 1 - 1
packages/@core/ui-kit/layout-ui/src/components/layout-tabbar.vue

@@ -37,7 +37,7 @@ const style = computed((): CSSProperties => {
 </script>
 
 <template>
-  <section :style="style" class="border-border flex w-full border-b">
+  <section :style="style" class="border-border flex w-full">
     <slot></slot>
     <div class="flex items-center">
       <slot name="toolbar"></slot>

+ 0 - 3
packages/@core/ui-kit/layout-ui/src/vben-layout.vue

@@ -533,9 +533,6 @@ function handleOpenMenu() {
           :style="tabbarStyle"
         >
           <slot name="tabbar"></slot>
-          <template #toolbar>
-            <slot name="tabbar-tools"></slot>
-          </template>
         </LayoutTabbar>
       </div>
 

+ 21 - 0
packages/@core/ui-kit/tabs-ui/build.config.ts

@@ -0,0 +1,21 @@
+import { defineBuildConfig } from 'unbuild';
+
+export default defineBuildConfig({
+  clean: true,
+  declaration: true,
+  entries: [
+    {
+      builder: 'mkdist',
+      input: './src',
+      loaders: ['vue'],
+      pattern: ['**/*.vue'],
+    },
+    {
+      builder: 'mkdist',
+      format: 'esm',
+      input: './src',
+      loaders: ['js'],
+      pattern: ['**/*.ts'],
+    },
+  ],
+});

+ 2 - 2
packages/@core/ui-kit/tabs-ui/package.json

@@ -11,7 +11,7 @@
   "license": "MIT",
   "type": "module",
   "scripts": {
-    "build": "pnpm vite build",
+    "build": "pnpm unbuild",
     "prepublishOnly": "npm run build"
   },
   "files": [
@@ -38,9 +38,9 @@
   },
   "dependencies": {
     "@vben-core/design": "workspace:*",
+    "@vben-core/hooks": "workspace:*",
     "@vben-core/iconify": "workspace:*",
     "@vben-core/shadcn-ui": "workspace:*",
-    "@vben-core/toolkit": "workspace:*",
     "@vben-core/typings": "workspace:*",
     "vue": "^3.4.31"
   }

+ 0 - 193
packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/chrome-tabs.scss

@@ -1,193 +0,0 @@
-@import '@vben-core/design/bem';
-
-@include b('chrome-tabs') {
-  --tabs-background: hsl(var(--background));
-  --tabs-gap: 7px;
-  --tabs-divider: hsl(var(--border));
-  --tabs-hover: hsl(var(--heavy));
-  --tabs-active-background: hsl(var(--primary) / 100%);
-  --tabs-active: hsl(var(--primary-foreground));
-
-  background-color: var(--tabs-background);
-}
-
-@include b('chrome-tab') {
-  color: hsl(var(--muted-foreground));
-
-  @include is('active') {
-    z-index: 2;
-    color: var(--tabs-active);
-
-    .#{$namespace}-chrome-tab__extra:not(.is-pin) {
-      background-color: var(--tabs-active-background);
-      opacity: 1;
-    }
-
-    .#{$namespace}-chrome-tab-background__divider {
-      display: none;
-    }
-
-    .#{$namespace}-chrome-tab-background__content {
-      background-color: var(--tabs-active-background);
-    }
-
-    .#{$namespace}-chrome-tab-background__before,
-    .#{$namespace}-chrome-tab-background__after {
-      fill: var(--tabs-active-background);
-    }
-  }
-
-  @include e('content') {
-    position: absolute;
-    right: 0;
-    left: 0;
-    display: flex;
-    align-items: center;
-    height: 100%;
-    padding-right: 10px;
-    margin: 0 calc(var(--tabs-gap) * 2);
-    overflow: hidden;
-    border-top-left-radius: 5px;
-    border-top-right-radius: 5px;
-  }
-
-  @include e('extra') {
-    position: absolute;
-    top: 50%;
-    right: calc(var(--tabs-gap) * 2);
-    z-index: 1;
-    width: 14px;
-    height: 14px;
-    border-radius: 50%;
-    opacity: 0;
-    transition: 0.15s;
-    transform: translateY(-50%);
-
-    // &:hover {
-    //   background-color: hsl(var(--accent));
-    // }
-  }
-
-  @include e('extra-icon') {
-    flex-shrink: 0;
-    width: 100%;
-    height: 100%;
-    font-size: 12px;
-    border-radius: 50%;
-    transition: all 0.15s ease;
-
-    &:hover {
-      color: hsl(var(--foreground));
-      transform: scale(1.05);
-    }
-  }
-
-  @include e('icon') {
-    display: flex;
-    align-items: center;
-    height: 16px;
-    margin-left: 3%;
-    overflow: hidden;
-
-    img {
-      height: 100%;
-    }
-  }
-
-  @include e('label') {
-    position: relative;
-    flex: 1;
-    margin-right: 8px;
-    margin-left: 5%;
-    overflow: hidden;
-    font-size: 14px;
-    white-space: nowrap;
-    mask-image: linear-gradient(
-      90deg,
-      #000 0%,
-      #000 calc(100% - 20px),
-      transparent
-    );
-
-    // &.no-close {
-    //   margin-right: 0;
-    // }
-
-    // &.no-icon {
-    //   margin-left: 0;
-    // }
-  }
-
-  @include is('hidden-icon') {
-    margin-left: 0;
-  }
-
-  &:hover {
-    .#{$namespace}-chrome-tab__extra.is-pin {
-      opacity: 1;
-    }
-  }
-
-  &:not(.is-active):hover {
-    z-index: 1;
-
-    .#{$namespace}-chrome-tab__extra {
-      opacity: 1;
-    }
-
-    .#{$namespace}-chrome-tab-background__divider {
-      display: none;
-    }
-
-    .#{$namespace}-chrome-tab-background__content {
-      background-color: var(--tabs-hover);
-    }
-
-    .#{$namespace}-chrome-tab-background__before,
-    .#{$namespace}-chrome-tab-background__after {
-      fill: var(--tabs-hover);
-    }
-  }
-
-  &:first-of-type {
-    .#{$namespace}-chrome-tab-background__divider::before {
-      display: none;
-    }
-  }
-}
-
-@include b('chrome-tab-background') {
-  padding: 0 calc(var(--tabs-gap) + 0px);
-
-  @include e('divider') {
-    width: calc(100% - 14px);
-    margin: 0 7px;
-
-    &::before {
-      background-color: var(--tabs-divider);
-    }
-
-    &::after {
-      left: calc(100% - 1px);
-      background-color: var(--tabs-divider);
-    }
-  }
-
-  @include e('content') {
-    border-top-left-radius: 5px;
-    border-top-right-radius: 5px;
-    transition: background 0.15s ease;
-  }
-
-  @include e('before') {
-    bottom: -1px;
-    left: -3px;
-    transition: 0.15s;
-  }
-
-  @include e('after') {
-    right: -3px;
-    bottom: -1px;
-    transition: 0.15s;
-  }
-}

+ 0 - 35
packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/tab-background.vue

@@ -1,35 +0,0 @@
-<script setup lang="ts">
-import { useNamespace } from '@vben-core/toolkit';
-
-defineOptions({
-  name: 'ChromeTabBackground',
-});
-
-const { b, e } = useNamespace('chrome-tab-background');
-</script>
-
-<template>
-  <div :class="b()" class="absolute size-full">
-    <div
-      :class="e('divider')"
-      class="absolute left-0 h-full before:absolute before:right-[100%] before:top-[15%] before:h-[60%] before:w-[1px] before:content-[''] after:absolute after:top-[15%] after:h-[60%] after:w-[1px] after:content-['']"
-    ></div>
-    <div :class="e('content')" class="h-full"></div>
-    <svg
-      :class="e('before')"
-      class="absolute fill-transparent"
-      height="10"
-      width="10"
-    >
-      <path d="M 0 10 A 10 10 0 0 0 10 0 L 10 10 Z" />
-    </svg>
-    <svg
-      :class="e('after')"
-      class="absolute fill-transparent"
-      height="10"
-      width="10"
-    >
-      <path d="M 0 0 A 10 10 0 0 0 10 10 L 0 10 Z" />
-    </svg>
-  </div>
-</template>

+ 0 - 76
packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/tab.vue

@@ -1,76 +0,0 @@
-<script setup lang="ts">
-import type { IContextMenuItem } from '@vben-core/shadcn-ui';
-import type { TabItem } from '@vben-core/typings';
-
-import { IcRoundClose, MdiPin } from '@vben-core/iconify';
-import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
-import { useNamespace } from '@vben-core/toolkit';
-
-import TabBackground from './tab-background.vue';
-
-interface Props {
-  affixTab?: boolean;
-  icon?: string;
-  menus: (data: any) => IContextMenuItem[];
-  onlyOne?: boolean;
-  showIcon?: boolean;
-  tab: TabItem;
-  title: string;
-}
-
-defineOptions({
-  name: 'ChromeTab',
-});
-
-withDefaults(defineProps<Props>(), {
-  icon: '',
-});
-const emit = defineEmits<{ close: []; unpinTab: [] }>();
-
-const { b, e, is } = useNamespace('chrome-tab');
-
-function handleClose() {
-  emit('close');
-}
-function handleUnpinTab() {
-  emit('unpinTab');
-}
-</script>
-
-<template>
-  <div
-    :class="[b()]"
-    class="absolute flex h-full cursor-pointer select-none items-center"
-  >
-    <VbenContextMenu
-      :handler-data="tab"
-      :menus="menus"
-      :modal="false"
-      item-class="pr-4"
-    >
-      <div class="h-full">
-        <TabBackground />
-        <div :class="e('content')" :title="title">
-          <VbenIcon v-if="showIcon" :class="e('icon')" :icon="icon" fallback />
-          <span :class="[e('label'), is('hidden-icon', !icon)]">
-            {{ title }}
-          </span>
-        </div>
-        <div
-          v-show="!affixTab && !onlyOne"
-          :class="e('extra')"
-          @click.stop="handleClose"
-        >
-          <IcRoundClose :class="e('extra-icon')" />
-        </div>
-        <div
-          v-show="affixTab && !onlyOne"
-          :class="[e('extra'), is('pin', true)]"
-          @click.stop="handleUnpinTab"
-        >
-          <MdiPin :class="e('extra-icon')" />
-        </div>
-      </div>
-    </VbenContextMenu>
-  </div>
-</template>

+ 0 - 114
packages/@core/ui-kit/tabs-ui/src/components/chrome-tabs/tabs.vue

@@ -1,114 +0,0 @@
-<script setup lang="ts">
-import type { TabItem } from '@vben-core/typings';
-
-import type { TabsProps } from '../../types';
-
-import { computed, nextTick, onMounted, ref, watch } from 'vue';
-
-import { useNamespace } from '@vben-core/toolkit';
-
-import Tab from './tab.vue';
-
-interface Props extends TabsProps {}
-
-defineOptions({
-  name: 'ChromeTabs',
-});
-
-const props = withDefaults(defineProps<Props>(), {
-  maxWidth: 150,
-  menus: () => [],
-  minWidth: 40,
-  tabs: () => [],
-});
-
-const emit = defineEmits<{ close: [string]; unpinTab: [TabItem] }>();
-
-const gap = 7;
-
-const active = defineModel<string>('active');
-const { b, e, is } = useNamespace('chrome-tabs');
-
-const contentRef = ref();
-const tabWidth = ref<number>(0);
-
-const layout = () => {
-  const { maxWidth, minWidth, tabs } = props;
-  if (!contentRef.value) {
-    return Math.max(maxWidth, minWidth);
-  }
-  const contentWidth = contentRef.value.clientWidth - gap * 3;
-  let width = contentWidth / tabs.length;
-  width += gap * 2;
-  if (width > maxWidth) {
-    width = maxWidth;
-  }
-  if (width < minWidth) {
-    width = minWidth;
-  }
-  tabWidth.value = width;
-};
-
-const tabsView = computed(() => {
-  return props.tabs.map((tab) => {
-    return {
-      ...tab,
-      affixTab: !!tab.meta?.affixTab,
-      icon: tab.meta.icon as string,
-      key: tab.fullPath || tab.path,
-      title: (tab.meta?.title || tab.name) as string,
-    };
-  });
-});
-
-watch(
-  () => props.tabs,
-  () => {
-    nextTick(() => {
-      layout();
-    });
-  },
-);
-
-onMounted(() => {
-  layout();
-});
-
-function handleClose(key: string) {
-  emit('close', key);
-}
-function handleUnpinTab(tab: TabItem) {
-  emit('unpinTab', tab);
-}
-</script>
-
-<template>
-  <div :class="b()" class="relative size-full pt-1">
-    <div ref="contentRef" class="relative h-8 overflow-hidden">
-      <TransitionGroup name="slide-down">
-        <Tab
-          v-for="(tab, i) in tabsView"
-          :key="tab.key"
-          :affix-tab="tab.affixTab"
-          :class="[e('tab'), is('active', tab.key === active)]"
-          :icon="tab.icon"
-          :menus="menus"
-          :only-one="tabsView.length <= 1"
-          :show-icon="showIcon"
-          :style="{
-            width: `${tabWidth}px`,
-            left: `${(tabWidth - gap * 2) * i}px`,
-          }"
-          :tab="tab"
-          :title="tab.title"
-          @click="active = tab.key"
-          @close="() => handleClose(tab.key)"
-          @unpin-tab="() => handleUnpinTab(tab)"
-        />
-      </TransitionGroup>
-    </div>
-  </div>
-</template>
-<style lang="scss">
-@import './chrome-tabs.scss';
-</style>

+ 1 - 1
packages/@core/ui-kit/tabs-ui/src/components/index.ts

@@ -1 +1 @@
-export { default as ChromeTabs } from './chrome-tabs/tabs.vue';
+export { default as TabsChrome } from './tabs-chrome/tabs.vue';

+ 264 - 0
packages/@core/ui-kit/tabs-ui/src/components/tabs-chrome/tabs.vue

@@ -0,0 +1,264 @@
+<script setup lang="ts">
+import type { TabItem } from '@vben-core/typings';
+
+import type { TabsProps } from '../../types';
+
+import { computed, nextTick, onMounted, ref, watch } from 'vue';
+
+import { MdiPin } from '@vben-core/iconify';
+import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
+
+interface Props extends TabsProps {}
+
+defineOptions({
+  name: 'TabsChrome',
+  // eslint-disable-next-line perfectionist/sort-objects
+  inheritAttrs: false,
+});
+
+const props = withDefaults(defineProps<Props>(), {
+  contentClass: 'vben-tabs-content',
+  contextMenus: () => [],
+  gap: 7,
+  maxWidth: 150,
+  minWidth: 40,
+  tabs: () => [],
+});
+
+const emit = defineEmits<{ close: [string]; unpin: [TabItem] }>();
+const active = defineModel<string>('active');
+
+const contentRef = ref();
+const tabRef = ref();
+const tabWidth = ref<number>(0);
+
+const style = computed(() => {
+  const { gap } = props;
+  return {
+    '--gap': `${gap}px`,
+  };
+});
+
+const layout = () => {
+  const { gap, maxWidth, minWidth, tabs } = props;
+  if (!contentRef.value) {
+    return Math.max(maxWidth, minWidth);
+  }
+  const contentWidth = contentRef.value.clientWidth - gap * 3;
+  let width = contentWidth / tabs.length;
+  width += gap * 2;
+  if (width > maxWidth) {
+    width = maxWidth;
+  }
+  if (width < minWidth) {
+    width = minWidth;
+  }
+  tabWidth.value = width;
+};
+
+const tabsView = computed(() => {
+  return props.tabs.map((tab) => {
+    return {
+      ...tab,
+      affixTab: !!tab.meta?.affixTab,
+      closable: tab.meta?.tabClosable ?? true,
+      icon: tab.meta.icon as string,
+      key: tab.fullPath || tab.path,
+      title: (tab.meta?.title || tab.name) as string,
+    };
+  });
+});
+
+watch(
+  () => props.tabs,
+  () => {
+    nextTick(() => {
+      layout();
+    });
+  },
+);
+
+onMounted(() => {
+  layout();
+});
+
+function handleClose(key: string) {
+  emit('close', key);
+}
+function handleUnpinTab(tab: TabItem) {
+  emit('unpin', tab);
+}
+</script>
+
+<template>
+  <div :style="style" class="tabs-chrome bg-accent size-full pt-1">
+    <!-- footer -> 4px -->
+    <div
+      ref="contentRef"
+      :class="contentClass"
+      class="relative h-full overflow-hidden"
+    >
+      <TransitionGroup name="slide-down">
+        <div
+          v-for="(tab, i) in tabsView"
+          :key="tab.key"
+          ref="tabRef"
+          :class="[
+            { 'is-active': tab.key === active, dragable: !tab.affixTab },
+          ]"
+          :data-index="i"
+          :style="{
+            width: `${tabWidth}px`,
+            left: `${(tabWidth - gap * 2) * i}px`,
+          }"
+          class="tabs-chrome__item group absolute flex h-full select-none items-center transition-all"
+          @click="active = tab.key"
+        >
+          <VbenContextMenu
+            :handler-data="tab"
+            :menus="contextMenus"
+            :modal="false"
+            item-class="pr-6"
+          >
+            <div class="size-full">
+              <!-- divider -->
+              <div
+                v-if="i !== 0"
+                class="tabs-chrome__divider bg-accent absolute left-[var(--gap)] top-1/2 z-0 h-5 w-[1px] translate-y-[-50%]"
+              ></div>
+              <!-- background -->
+              <div
+                class="tabs-chrome__background absolute z-[1] size-full px-[calc(var(--gap)-1px)] py-0 transition-opacity duration-150"
+              >
+                <div
+                  class="tabs-chrome__background-content h-full rounded-tl-[var(--gap)] rounded-tr-[var(--gap)] duration-150"
+                ></div>
+                <svg
+                  class="tabs-chrome__background-before absolute bottom-[-1px] left-[-1px] fill-transparent transition-all duration-150"
+                  height="7"
+                  width="7"
+                >
+                  <path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z" />
+                </svg>
+                <svg
+                  class="tabs-chrome__background-after absolute bottom-[-1px] right-[-1px] fill-transparent transition-all duration-150"
+                  height="7"
+                  width="7"
+                >
+                  <path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z" />
+                </svg>
+              </div>
+
+              <!-- extra -->
+              <div
+                class="tabs-chrome__extra absolute right-[calc(var(--gap)*2)] top-1/2 z-[3] size-4 translate-y-[-50%] opacity-0 transition-opacity group-hover:opacity-100"
+              >
+                <!-- close-icon -->
+                <svg
+                  v-show="!tab.affixTab && tabsView.length > 1 && tab.closable"
+                  class="hover:bg-accent hover:stroke-accent-foreground size-full cursor-pointer rounded-full transition-all"
+                  height="12"
+                  stroke="#595959"
+                  width="12"
+                  @click.stop="handleClose(tab.key)"
+                >
+                  <path d="M 4 4 L 12 12 M 12 4 L 4 12" />
+                </svg>
+                <MdiPin
+                  v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
+                  class="hover:bg-accent hover:stroke-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
+                  @click.stop="handleUnpinTab(tab)"
+                />
+              </div>
+
+              <!-- tab-item-main -->
+              <div
+                class="tabs-chrome__item-main absolute left-0 right-0 z-[2] mx-[calc(var(--gap)*2)] my-0 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] duration-150 group-hover:pr-3"
+              >
+                <VbenIcon
+                  v-if="showIcon"
+                  :icon="tab.icon"
+                  class="ml-[var(--gap)] flex size-4 items-center overflow-hidden"
+                  fallback
+                />
+
+                <span
+                  class="tabs-chrome__label ml-[var(--gap)] flex-1 overflow-hidden whitespace-nowrap"
+                >
+                  {{ tab.title }}
+                </span>
+              </div>
+            </div>
+          </VbenContextMenu>
+        </div>
+      </TransitionGroup>
+    </div>
+    <!-- footer -->
+    <div class="bg-background h-1"></div>
+  </div>
+</template>
+
+<style scoped>
+.tabs-chrome {
+  .dragging {
+    .tabs-chrome__item-main {
+      @apply pr-0;
+    }
+
+    .tabs-chrome__extra {
+      @apply hidden;
+    }
+  }
+
+  &__item {
+    &:hover {
+      & + .tabs-chrome__item {
+        .tabs-chrome__divider {
+          @apply opacity-0;
+        }
+      }
+
+      .tabs-chrome__divider {
+        @apply opacity-0;
+      }
+
+      .tabs-chrome__background {
+        &-content {
+          @apply bg-accent;
+        }
+
+        &-before,
+        &-after {
+          @apply fill-accent;
+        }
+      }
+    }
+
+    &.is-active {
+      @apply z-[2];
+
+      .tabs-chrome__background {
+        @apply opacity-100;
+
+        &-content {
+          @apply bg-background;
+        }
+
+        &-before,
+        &-after {
+          @apply fill-background;
+        }
+      }
+    }
+  }
+
+  &__label {
+    mask-image: linear-gradient(
+      90deg,
+      #000 0%,
+      #000 calc(100% - 16px),
+      transparent
+    );
+  }
+}
+</style>

+ 0 - 0
packages/@core/ui-kit/tabs-ui/src/widgets/index.ts → packages/@core/ui-kit/tabs-ui/src/components/widgets/index.ts


+ 1 - 1
packages/@core/ui-kit/tabs-ui/src/widgets/tool-more.vue → packages/@core/ui-kit/tabs-ui/src/components/widgets/tool-more.vue

@@ -10,7 +10,7 @@ defineProps<DropdownMenuProps>();
 <template>
   <VbenDropdownMenu :menus="menus" :modal="false">
     <div
-      class="flex-center hover:bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-1.5 text-lg font-semibold"
+      class="flex-center hover:bg-muted bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-1.5 text-lg font-semibold"
     >
       <IcRoundKeyboardArrowDown class="size-5" />
     </div>

+ 1 - 1
packages/@core/ui-kit/tabs-ui/src/widgets/tool-screen.vue → packages/@core/ui-kit/tabs-ui/src/components/widgets/tool-screen.vue

@@ -10,7 +10,7 @@ function toggleScreen() {
 
 <template>
   <div
-    class="flex-center hover:bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-2 text-lg font-semibold"
+    class="flex-center hover:bg-muted bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-2 text-lg font-semibold"
     @click="toggleScreen"
   >
     <IcTwotoneFitScreen v-if="screen" />

+ 1 - 1
packages/@core/ui-kit/tabs-ui/src/index.ts

@@ -1,3 +1,3 @@
+export * from './components/widgets';
 export { default as TabsView } from './tabs-view.vue';
-export * from './widgets';
 export type { IContextMenuItem } from '@vben-core/shadcn-ui';

+ 80 - 4
packages/@core/ui-kit/tabs-ui/src/tabs-view.vue

@@ -1,9 +1,12 @@
 <script setup lang="ts">
 import type { TabItem } from '@vben-core/typings';
 
+import { nextTick, onMounted } from 'vue';
+
+import { useSortable } from '@vben-core/hooks';
 import { useForwardPropsEmits } from '@vben-core/shadcn-ui';
 
-import { ChromeTabs } from './components';
+import { TabsChrome } from './components';
 import { TabsProps } from './types';
 
 interface Props extends TabsProps {}
@@ -12,13 +15,86 @@ defineOptions({
   name: 'TabsView',
 });
 
-const props = withDefaults(defineProps<Props>(), {});
+const props = withDefaults(defineProps<Props>(), {
+  contentClass: 'vben-tabs-content',
+  dragable: true,
+});
 
-const emit = defineEmits<{ close: [string]; unPushPin: [TabItem] }>();
+const emit = defineEmits<{
+  close: [string];
+  sortTabs: [number, number];
+  unpin: [TabItem];
+}>();
 
 const forward = useForwardPropsEmits(props, emit);
+
+// 可能会找到拖拽的子元素,这里需要确保拖拽的dom时tab元素
+const findParentElement = (element: HTMLElement) => {
+  const parentCls = 'group';
+  return element.classList.contains(parentCls)
+    ? element
+    : element.closest(`.${parentCls}`);
+};
+
+async function initTabsSortable() {
+  await nextTick();
+  const { contentClass } = props;
+
+  const el = document.querySelectorAll(`.${contentClass}`)?.[0] as HTMLElement;
+
+  const { initializeSortable } = useSortable(el, {
+    filter: (_evt, target: HTMLElement) => {
+      const parent = findParentElement(target);
+      const dragable = parent?.classList.contains('dragable');
+      return !dragable || !props.dragable;
+    },
+    onEnd(evt) {
+      const { newIndex, oldIndex } = evt;
+      // const fromElement = evt.item;
+      const { srcElement } = (evt as any).originalEvent;
+
+      if (!srcElement) {
+        return;
+      }
+
+      const srcParent = findParentElement(srcElement);
+
+      if (!srcParent) {
+        return;
+      }
+
+      if (!srcParent.classList.contains('dragable')) {
+        return;
+      }
+
+      if (
+        oldIndex !== undefined &&
+        newIndex !== undefined &&
+        !Number.isNaN(oldIndex) &&
+        !Number.isNaN(newIndex) &&
+        oldIndex !== newIndex
+      ) {
+        emit('sortTabs', oldIndex, newIndex);
+      }
+      el.classList.remove('dragging');
+      el.style.cursor = 'default';
+    },
+    onMove(evt) {
+      const parent = findParentElement(evt.related);
+      return parent?.classList.contains('dragable');
+    },
+    onStart: () => {
+      el.style.cursor = 'grabbing';
+      el.classList.add('dragging');
+    },
+  });
+
+  await initializeSortable();
+}
+
+onMounted(initTabsSortable);
 </script>
 
 <template>
-  <ChromeTabs v-bind="forward" />
+  <TabsChrome v-bind="forward" />
 </template>

+ 36 - 1
packages/@core/ui-kit/tabs-ui/src/types.ts

@@ -2,10 +2,45 @@ import type { IContextMenuItem } from '@vben-core/shadcn-ui';
 import type { TabItem } from '@vben-core/typings';
 
 interface TabsProps {
+  /**
+   * @zh_CN content class
+   * @default tabs-chrome
+   */
+  contentClass?: string;
+  /**
+   * @zh_CN 右键菜单
+   */
+  contextMenus?: (data: any) => IContextMenuItem[];
+  /**
+   * @zh_CN 是否可以拖拽
+   */
+  dragable?: boolean;
+  /**
+   * @zh_CN 间隙
+   * @default 7
+   * 仅限 tabs-chrome
+   */
+  gap?: number;
+
+  /**
+   * @zh_CN tab 最大宽度
+   * 仅限 tabs-chrome
+   */
   maxWidth?: number;
-  menus?: (data: any) => IContextMenuItem[];
+
+  /**
+   * @zh_CN tab最小宽度
+   * 仅限 tabs-chrome
+   */
   minWidth?: number;
+  /**
+   * @zh_CN 是否显示图标
+   */
   showIcon?: boolean;
+
+  /**
+   * @zh_CN 选项卡数据
+   */
   tabs?: TabItem[];
 }
 

+ 0 - 3
packages/@core/ui-kit/tabs-ui/vite.config.mts

@@ -1,3 +0,0 @@
-import { defineConfig } from '@vben/vite-config';
-
-export default defineConfig();

+ 1 - 4
packages/effects/layouts/src/basic/layout.vue

@@ -24,7 +24,7 @@ import {
   useExtraMenu,
   useMixedMenu,
 } from './menu';
-import { LayoutTabbar, LayoutTabbarTools } from './tabbar';
+import { LayoutTabbar } from './tabbar';
 
 defineOptions({ name: 'BasicLayout' });
 
@@ -260,9 +260,6 @@ function clearPreferencesAndLogout() {
         :show-icon="preferences.tabbar.showIcon"
       />
     </template>
-    <template #tabbar-tools>
-      <LayoutTabbarTools v-if="preferences.tabbar.enable" />
-    </template>
 
     <!-- 主体内容 -->
     <template #content>

+ 0 - 1
packages/effects/layouts/src/basic/tabbar/index.ts

@@ -1,3 +1,2 @@
 export { default as LayoutTabbar } from './tabbar.vue';
-export { default as LayoutTabbarTools } from './tabbar-tools.vue';
 export * from './use-tabs';

+ 0 - 28
packages/effects/layouts/src/basic/tabbar/tabbar-tools.vue

@@ -1,28 +0,0 @@
-<script lang="ts" setup>
-import { computed } from 'vue';
-import { useRoute } from 'vue-router';
-
-import { preferences } from '@vben-core/preferences';
-import { TabsToolMore, TabsToolScreen } from '@vben-core/tabs-ui';
-
-import { updateContentScreen, useTabs } from './use-tabs';
-
-const route = useRoute();
-
-const { createContextMenus } = useTabs();
-
-const menus = computed(() => {
-  return createContextMenus(route);
-});
-</script>
-
-<template>
-  <div class="flex-center h-full">
-    <TabsToolMore :menus="menus" />
-    <TabsToolScreen
-      :screen="preferences.sidebar.hidden"
-      @change="updateContentScreen"
-      @update:screen="updateContentScreen"
-    />
-  </div>
-</template>

+ 20 - 6
packages/effects/layouts/src/basic/tabbar/tabbar.vue

@@ -1,11 +1,12 @@
 <script lang="ts" setup>
+import { computed } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { preferences } from '@vben-core/preferences';
 import { useCoreTabbarStore } from '@vben-core/stores';
-import { TabsView } from '@vben-core/tabs-ui';
+import { TabsToolMore, TabsToolScreen, TabsView } from '@vben-core/tabs-ui';
 
-import { useTabs } from './use-tabs';
+import { updateContentScreen, useTabs } from './use-tabs';
 
 defineOptions({
   name: 'LayoutTabbar',
@@ -13,10 +14,10 @@ defineOptions({
 
 defineProps<{ showIcon?: boolean }>();
 
-const route = useRoute();
-
 const coreTabbarStore = useCoreTabbarStore();
 
+const route = useRoute();
+
 const {
   createContextMenus,
   currentActive,
@@ -26,6 +27,10 @@ const {
   handleUnpinTab,
 } = useTabs();
 
+const menus = computed(() => {
+  return createContextMenus(route);
+});
+
 // 刷新后如果不保持tab状态,关闭其他tab
 if (!preferences.tabbar.persist) {
   coreTabbarStore.closeOtherTabs(route);
@@ -35,11 +40,20 @@ if (!preferences.tabbar.persist) {
 <template>
   <TabsView
     :active="currentActive"
-    :menus="createContextMenus"
+    :context-menus="createContextMenus"
     :show-icon="showIcon"
     :tabs="currentTabs"
     @close="handleClose"
-    @unpin-tab="handleUnpinTab"
+    @sort-tabs="coreTabbarStore.sortTabs"
+    @unpin="handleUnpinTab"
     @update:active="handleClick"
   />
+  <div class="flex-center h-full">
+    <TabsToolMore :menus="menus" />
+    <TabsToolScreen
+      :screen="preferences.sidebar.hidden"
+      @change="updateContentScreen"
+      @update:screen="updateContentScreen"
+    />
+  </div>
 </template>

+ 24 - 23
packages/effects/layouts/src/basic/tabbar/use-tabs.ts

@@ -83,8 +83,8 @@ function useTabs() {
     return {
       ...tab,
       meta: {
-        ...tab.meta,
-        title: $t(tab.meta.title as string),
+        ...tab?.meta,
+        title: $t(tab?.meta?.title as string),
       },
     };
   }
@@ -126,6 +126,27 @@ function useTabs() {
       disabled || !isCurrentTab || tabs.length - affixTabs.length <= 1;
 
     const menus: IContextMenuItem[] = [
+      {
+        disabled: !!affixTab || disabled,
+        handler: async () => {
+          await coreTabbarStore.closeTab(tab, router);
+        },
+        icon: IcRoundClose,
+        key: 'close',
+        text: $t('preferences.tabbar.contextMenu.close'),
+      },
+      {
+        handler: async () => {
+          await (affixTab
+            ? coreTabbarStore.unpinTab(tab)
+            : coreTabbarStore.pinTab(tab));
+        },
+        icon: affixTab ? MdiPinOff : MdiPin,
+        key: 'affix',
+        text: affixTab
+          ? $t('preferences.tabbar.contextMenu.unpin')
+          : $t('preferences.tabbar.contextMenu.pin'),
+      },
       {
         handler: async () => {
           if (!contentIsMaximize.value) {
@@ -148,27 +169,7 @@ function useTabs() {
         key: 'reload',
         text: $t('preferences.tabbar.contextMenu.reload'),
       },
-      {
-        disabled: !!affixTab || disabled,
-        handler: async () => {
-          await coreTabbarStore.closeTab(tab, router);
-        },
-        icon: IcRoundClose,
-        key: 'close',
-        text: $t('preferences.tabbar.contextMenu.close'),
-      },
-      {
-        handler: async () => {
-          await (affixTab
-            ? coreTabbarStore.unpinTab(tab)
-            : coreTabbarStore.pinTab(tab));
-        },
-        icon: affixTab ? MdiPinOff : MdiPin,
-        key: 'affix',
-        text: affixTab
-          ? $t('preferences.tabbar.contextMenu.unpin')
-          : $t('preferences.tabbar.contextMenu.pin'),
-      },
+
       {
         handler: async () => {
           const { hash, origin } = location;

+ 32 - 9
pnpm-lock.yaml

@@ -653,6 +653,19 @@ importers:
         specifier: ^2.0.0
         version: 2.0.0
 
+  packages/@core/shared/hooks:
+    dependencies:
+      sortablejs:
+        specifier: ^1.15.2
+        version: 1.15.2
+      vue:
+        specifier: ^3.4.31
+        version: 3.4.31(typescript@5.5.3)
+    devDependencies:
+      '@types/sortablejs':
+        specifier: ^1.15.8
+        version: 1.15.8
+
   packages/@core/shared/iconify:
     dependencies:
       '@iconify/vue':
@@ -785,15 +798,15 @@ importers:
       '@vben-core/design':
         specifier: workspace:*
         version: link:../../shared/design
+      '@vben-core/hooks':
+        specifier: workspace:*
+        version: link:../../shared/hooks
       '@vben-core/iconify':
         specifier: workspace:*
         version: link:../../shared/iconify
       '@vben-core/shadcn-ui':
         specifier: workspace:*
         version: link:../shadcn-ui
-      '@vben-core/toolkit':
-        specifier: workspace:*
-        version: link:../../shared/toolkit
       '@vben-core/typings':
         specifier: workspace:*
         version: link:../../shared/typings
@@ -862,7 +875,7 @@ importers:
         version: link:../../types
       '@vueuse/integrations':
         specifier: ^10.11.0
-        version: 10.11.0(async-validator@4.2.5)(axios@1.7.2)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.3)(vue@3.4.31(typescript@5.5.3))
+        version: 10.11.0(async-validator@4.2.5)(axios@1.7.2)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.3)(sortablejs@1.15.2)(vue@3.4.31(typescript@5.5.3))
       qrcode:
         specifier: ^1.5.3
         version: 1.5.3
@@ -976,7 +989,7 @@ importers:
     devDependencies:
       vitepress:
         specifier: ^1.3.0
-        version: 1.3.0(@algolia/client-search@4.24.0)(@types/node@20.14.10)(async-validator@4.2.5)(axios@1.7.2)(nprogress@0.2.0)(postcss@8.4.39)(qrcode@1.5.3)(sass@1.77.8)(search-insights@2.15.0)(terser@5.31.2)(typescript@5.5.3)
+        version: 1.3.0(@algolia/client-search@4.24.0)(@types/node@20.14.10)(async-validator@4.2.5)(axios@1.7.2)(nprogress@0.2.0)(postcss@8.4.39)(qrcode@1.5.3)(sass@1.77.8)(search-insights@2.15.0)(sortablejs@1.15.2)(terser@5.31.2)(typescript@5.5.3)
       vue:
         specifier: ^3.4.31
         version: 3.4.31(typescript@5.5.3)
@@ -3008,7 +3021,6 @@ packages:
 
   '@ls-lint/ls-lint@2.2.3':
     resolution: {integrity: sha512-ekM12jNm/7O2I/hsRv9HvYkRdfrHpiV1epVuI2NP+eTIcEgdIdKkKCs9KgQydu/8R5YXTov9aHdOgplmCHLupw==}
-    cpu: [x64, arm64, s390x]
     os: [darwin, linux, win32]
     hasBin: true
 
@@ -3585,6 +3597,9 @@ packages:
   '@types/serve-static@1.15.7':
     resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
 
+  '@types/sortablejs@1.15.8':
+    resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
+
   '@types/tough-cookie@4.0.5':
     resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
 
@@ -8188,6 +8203,9 @@ packages:
     resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==}
     engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
 
+  sortablejs@1.15.2:
+    resolution: {integrity: sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==}
+
   source-map-js@1.2.0:
     resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
     engines: {node: '>=0.10.0'}
@@ -12315,6 +12333,8 @@ snapshots:
       '@types/node': 20.14.10
       '@types/send': 0.17.4
 
+  '@types/sortablejs@1.15.8': {}
+
   '@types/tough-cookie@4.0.5': {}
 
   '@types/trusted-types@2.0.7': {}
@@ -12640,7 +12660,7 @@ snapshots:
       - '@vue/composition-api'
       - vue
 
-  '@vueuse/integrations@10.11.0(async-validator@4.2.5)(axios@1.7.2)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.3)(vue@3.4.31(typescript@5.5.3))':
+  '@vueuse/integrations@10.11.0(async-validator@4.2.5)(axios@1.7.2)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.3)(sortablejs@1.15.2)(vue@3.4.31(typescript@5.5.3))':
     dependencies:
       '@vueuse/core': 10.11.0(vue@3.4.31(typescript@5.5.3))
       '@vueuse/shared': 10.11.0(vue@3.4.31(typescript@5.5.3))
@@ -12651,6 +12671,7 @@ snapshots:
       focus-trap: 7.5.4
       nprogress: 0.2.0
       qrcode: 1.5.3
+      sortablejs: 1.15.2
     transitivePeerDependencies:
       - '@vue/composition-api'
       - vue
@@ -17378,6 +17399,8 @@ snapshots:
       ip-address: 9.0.5
       smart-buffer: 4.2.0
 
+  sortablejs@1.15.2: {}
+
   source-map-js@1.2.0: {}
 
   source-map-support@0.5.21:
@@ -18323,7 +18346,7 @@ snapshots:
       sass: 1.77.8
       terser: 5.31.2
 
-  vitepress@1.3.0(@algolia/client-search@4.24.0)(@types/node@20.14.10)(async-validator@4.2.5)(axios@1.7.2)(nprogress@0.2.0)(postcss@8.4.39)(qrcode@1.5.3)(sass@1.77.8)(search-insights@2.15.0)(terser@5.31.2)(typescript@5.5.3):
+  vitepress@1.3.0(@algolia/client-search@4.24.0)(@types/node@20.14.10)(async-validator@4.2.5)(axios@1.7.2)(nprogress@0.2.0)(postcss@8.4.39)(qrcode@1.5.3)(sass@1.77.8)(search-insights@2.15.0)(sortablejs@1.15.2)(terser@5.31.2)(typescript@5.5.3):
     dependencies:
       '@docsearch/css': 3.6.0
       '@docsearch/js': 3.6.0(@algolia/client-search@4.24.0)(search-insights@2.15.0)
@@ -18334,7 +18357,7 @@ snapshots:
       '@vue/devtools-api': 7.3.5
       '@vue/shared': 3.4.31
       '@vueuse/core': 10.11.0(vue@3.4.31(typescript@5.5.3))
-      '@vueuse/integrations': 10.11.0(async-validator@4.2.5)(axios@1.7.2)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.3)(vue@3.4.31(typescript@5.5.3))
+      '@vueuse/integrations': 10.11.0(async-validator@4.2.5)(axios@1.7.2)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.3)(sortablejs@1.15.2)(vue@3.4.31(typescript@5.5.3))
       focus-trap: 7.5.4
       mark.js: 8.11.1
       minisearch: 6.3.0

+ 4 - 0
vben-admin.code-workspace

@@ -72,6 +72,10 @@
       "name": "@vben-core/design",
       "path": "packages/@core/shared/design",
     },
+    {
+      "name": "@vben-core/hooks",
+      "path": "packages/@core/shared/hooks",
+    },
     {
       "name": "@vben-core/iconify",
       "path": "packages/@core/shared/iconify",