Browse Source

perf: optimization of tabbar display (#4169)

* perf: optimization of tabbar display

* fix: ci error

* chore: typo

* chore: typo
Vben 7 months ago
parent
commit
0faf7810b6
38 changed files with 715 additions and 509 deletions
  1. 2 2
      apps/web-antd/package.json
  2. 2 2
      apps/web-ele/package.json
  3. 2 2
      apps/web-naive/package.json
  4. 1 1
      docs/package.json
  5. 2 1
      internal/lint-configs/eslint-config/src/configs/typescript.ts
  6. 1 1
      package.json
  7. 2 2
      packages/@core/base/icons/package.json
  8. 1 1
      packages/@core/base/shared/package.json
  9. 1 1
      packages/@core/base/typings/package.json
  10. 19 16
      packages/@core/base/typings/src/helper.d.ts
  11. 2 2
      packages/@core/composables/package.json
  12. 11 7
      packages/@core/composables/src/use-content-style.ts
  13. 1 1
      packages/@core/composables/src/use-sortable.test.ts
  14. 1 1
      packages/@core/composables/src/use-sortable.ts
  15. 2 2
      packages/@core/preferences/package.json
  16. 2 2
      packages/@core/ui-kit/layout-ui/package.json
  17. 2 2
      packages/@core/ui-kit/menu-ui/package.json
  18. 3 3
      packages/@core/ui-kit/shadcn-ui/package.json
  19. 71 5
      packages/@core/ui-kit/shadcn-ui/src/components/scrollbar/scrollbar.vue
  20. 2 1
      packages/@core/ui-kit/tabs-ui/package.json
  21. 104 160
      packages/@core/ui-kit/tabs-ui/src/components/tabs-chrome/tabs.vue
  22. 64 98
      packages/@core/ui-kit/tabs-ui/src/components/tabs/tabs.vue
  23. 50 120
      packages/@core/ui-kit/tabs-ui/src/tabs-view.vue
  24. 9 4
      packages/@core/ui-kit/tabs-ui/src/types.ts
  25. 110 0
      packages/@core/ui-kit/tabs-ui/src/use-tabs-drag.ts
  26. 147 13
      packages/@core/ui-kit/tabs-ui/src/use-tabs-view-scroll.ts
  27. 1 1
      packages/effects/access/package.json
  28. 2 2
      packages/effects/chart-ui/package.json
  29. 2 2
      packages/effects/common-ui/package.json
  30. 1 1
      packages/effects/hooks/package.json
  31. 2 2
      packages/effects/layouts/package.json
  32. 1 1
      packages/locales/package.json
  33. 1 1
      packages/stores/package.json
  34. 14 3
      packages/stores/src/modules/tabbar.ts
  35. 1 1
      packages/types/package.json
  36. 2 2
      playground/package.json
  37. 1 1
      playground/src/router/routes/modules/demos.ts
  38. 73 42
      pnpm-lock.yaml

+ 2 - 2
apps/web-antd/package.json

@@ -40,11 +40,11 @@
     "@vben/styles": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
-    "@vueuse/core": "^10.11.1",
+    "@vueuse/core": "^11.0.0",
     "ant-design-vue": "^4.2.3",
     "dayjs": "^1.11.12",
     "pinia": "2.2.2",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   }
 }

+ 2 - 2
apps/web-ele/package.json

@@ -40,11 +40,11 @@
     "@vben/styles": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
-    "@vueuse/core": "^10.11.1",
+    "@vueuse/core": "^11.0.0",
     "dayjs": "^1.11.12",
     "element-plus": "^2.8.0",
     "pinia": "2.2.2",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   },
   "devDependencies": {

+ 2 - 2
apps/web-naive/package.json

@@ -40,10 +40,10 @@
     "@vben/styles": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
-    "@vueuse/core": "^10.11.1",
+    "@vueuse/core": "^11.0.0",
     "naive-ui": "^2.39.0",
     "pinia": "2.2.2",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   }
 }

+ 1 - 1
docs/package.json

@@ -14,6 +14,6 @@
     "@nolebase/vitepress-plugin-git-changelog": "^2.4.0",
     "@vite-pwa/vitepress": "^0.5.0",
     "vitepress": "^1.3.2",
-    "vue": "^3.4.38"
+    "vue": "^3.4.37"
   }
 }

+ 2 - 1
internal/lint-configs/eslint-config/src/configs/typescript.ts

@@ -42,7 +42,8 @@ export async function typescript(): Promise<Linter.Config[]> {
           },
         ],
 
-        '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
+        // '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
+        '@typescript-eslint/consistent-type-definitions': 'off',
         '@typescript-eslint/explicit-function-return-type': 'off',
         '@typescript-eslint/explicit-module-boundary-types': 'off',
         '@typescript-eslint/no-empty-function': [

+ 1 - 1
package.json

@@ -94,7 +94,7 @@
     "node": ">=20",
     "pnpm": ">=9"
   },
-  "packageManager": "pnpm@9.7.0",
+  "packageManager": "pnpm@9.7.1",
   "pnpm": {
     "peerDependencyRules": {
       "allowedVersions": {

+ 2 - 2
packages/@core/base/icons/package.json

@@ -35,7 +35,7 @@
   },
   "dependencies": {
     "@iconify/vue": "^4.1.2",
-    "lucide-vue-next": "^0.427.0",
-    "vue": "^3.4.38"
+    "lucide-vue-next": "^0.428.0",
+    "vue": "^3.4.37"
   }
 }

+ 1 - 1
packages/@core/base/shared/package.json

@@ -56,7 +56,7 @@
   },
   "dependencies": {
     "@ctrl/tinycolor": "^4.1.0",
-    "@vue/shared": "^3.4.38",
+    "@vue/shared": "^3.4.37",
     "clsx": "^2.1.1",
     "defu": "^6.1.4",
     "lodash.clonedeep": "^4.5.0",

+ 1 - 1
packages/@core/base/typings/package.json

@@ -38,7 +38,7 @@
     }
   },
   "dependencies": {
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   }
 }

+ 19 - 16
packages/@core/base/typings/src/helper.d.ts

@@ -107,20 +107,23 @@ type MergeAll<
   ? MergeAll<Rest, Merge<R, F>>
   : R;
 
-export {
-  type AnyFunction,
-  type AnyNormalFunction,
-  type AnyPromiseFunction,
-  type DeepPartial,
-  type DeepReadonly,
-  type IntervalHandle,
-  type MaybeComputedRef,
-  type MaybeReadonlyRef,
-  type Merge,
-  type MergeAll,
-  type NonNullable,
-  type Nullable,
-  type ReadonlyRecordable,
-  type Recordable,
-  type TimeoutHandle,
+type EmitType = (name: Name, ...args: any[]) => void;
+
+export type {
+  AnyFunction,
+  AnyNormalFunction,
+  AnyPromiseFunction,
+  DeepPartial,
+  DeepReadonly,
+  EmitType,
+  IntervalHandle,
+  MaybeComputedRef,
+  MaybeReadonlyRef,
+  Merge,
+  MergeAll,
+  NonNullable,
+  Nullable,
+  ReadonlyRecordable,
+  Recordable,
+  TimeoutHandle,
 };

+ 2 - 2
packages/@core/composables/package.json

@@ -36,10 +36,10 @@
   },
   "dependencies": {
     "@vben-core/shared": "workspace:*",
-    "@vueuse/core": "^10.11.1",
+    "@vueuse/core": "^11.0.0",
     "radix-vue": "^1.9.4",
     "sortablejs": "^1.15.2",
-    "vue": "^3.4.38"
+    "vue": "^3.4.37"
   },
   "devDependencies": {
     "@types/sortablejs": "^1.15.8"

+ 11 - 7
packages/@core/composables/src/use-content-style.ts

@@ -1,5 +1,5 @@
 import type { CSSProperties } from 'vue';
-import { computed, nextTick, onMounted, ref } from 'vue';
+import { computed, onMounted, onUnmounted, ref } from 'vue';
 
 import {
   CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
@@ -14,6 +14,7 @@ import { useCssVar, useDebounceFn } from '@vueuse/core';
  * @zh_CN content style
  */
 function useContentStyle() {
+  let resizeObserver: null | ResizeObserver = null;
   const contentElement = ref<HTMLDivElement | null>(null);
   const visibleDomRect = ref<null | VisibleDomRect>(null);
   const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
@@ -41,12 +42,15 @@ function useContentStyle() {
   );
 
   onMounted(() => {
-    nextTick(() => {
-      if (contentElement.value) {
-        const observer = new ResizeObserver(debouncedCalcHeight);
-        observer.observe(contentElement.value);
-      }
-    });
+    if (contentElement.value && !resizeObserver) {
+      resizeObserver = new ResizeObserver(debouncedCalcHeight);
+      resizeObserver.observe(contentElement.value);
+    }
+  });
+
+  onUnmounted(() => {
+    resizeObserver?.disconnect();
+    resizeObserver = null;
   });
 
   return { contentElement, overlayStyle, visibleDomRect };

+ 1 - 1
packages/@core/composables/src/use-sortable.test.ts

@@ -39,7 +39,7 @@ describe('useSortable', () => {
     expect(Sortable.default.create).toHaveBeenCalledWith(
       mockElement,
       expect.objectContaining({
-        animation: 100,
+        animation: 300,
         delay: 400,
         delayOnTouchOnly: true,
         ...customOptions,

+ 1 - 1
packages/@core/composables/src/use-sortable.ts

@@ -18,7 +18,7 @@ function useSortable<T extends HTMLElement>(
     // Sortable?.default?.mount?.(AutoScroll);
 
     const sortable = Sortable?.default?.create?.(sortableContainer, {
-      animation: 100,
+      animation: 300,
       delay: 400,
       delayOnTouchOnly: true,
       ...options,

+ 2 - 2
packages/@core/preferences/package.json

@@ -31,7 +31,7 @@
   "dependencies": {
     "@vben-core/shared": "workspace:*",
     "@vben-core/typings": "workspace:*",
-    "@vueuse/core": "^10.11.1",
-    "vue": "^3.4.38"
+    "@vueuse/core": "^11.0.0",
+    "vue": "^3.4.37"
   }
 }

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

@@ -41,7 +41,7 @@
     "@vben-core/icons": "workspace:*",
     "@vben-core/shadcn-ui": "workspace:*",
     "@vben-core/typings": "workspace:*",
-    "@vueuse/core": "^10.11.1",
-    "vue": "^3.4.38"
+    "@vueuse/core": "^11.0.0",
+    "vue": "^3.4.37"
   }
 }

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

@@ -42,7 +42,7 @@
     "@vben-core/shadcn-ui": "workspace:*",
     "@vben-core/shared": "workspace:*",
     "@vben-core/typings": "workspace:*",
-    "@vueuse/core": "^10.11.1",
-    "vue": "^3.4.38"
+    "@vueuse/core": "^11.0.0",
+    "vue": "^3.4.37"
   }
 }

+ 3 - 3
packages/@core/ui-kit/shadcn-ui/package.json

@@ -46,10 +46,10 @@
     "@vben-core/icons": "workspace:*",
     "@vben-core/shared": "workspace:*",
     "@vben-core/typings": "workspace:*",
-    "@vueuse/core": "^10.11.1",
+    "@vueuse/core": "^11.0.0",
     "class-variance-authority": "^0.7.0",
-    "lucide-vue-next": "^0.427.0",
+    "lucide-vue-next": "^0.428.0",
     "radix-vue": "^1.9.4",
-    "vue": "^3.4.38"
+    "vue": "^3.4.37"
   }
 }

+ 71 - 5
packages/@core/ui-kit/shadcn-ui/src/components/scrollbar/scrollbar.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
 
 import { cn } from '@vben-core/shared';
 
@@ -11,6 +11,10 @@ interface Props {
   scrollBarClass?: any;
   shadow?: boolean;
   shadowBorder?: boolean;
+  shadowBottom?: boolean;
+  shadowLeft?: boolean;
+  shadowRight?: boolean;
+  shadowTop?: boolean;
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -18,29 +22,66 @@ const props = withDefaults(defineProps<Props>(), {
   horizontal: false,
   shadow: false,
   shadowBorder: false,
+  shadowBottom: true,
+  shadowLeft: false,
+  shadowRight: false,
+  shadowTop: true,
 });
 
+const emit = defineEmits<{
+  scrollAt: [{ bottom: boolean; left: boolean; right: boolean; top: boolean }];
+}>();
+
 const isAtTop = ref(true);
+const isAtRight = ref(false);
 const isAtBottom = ref(false);
+const isAtLeft = ref(true);
+
+const showShadowTop = computed(() => props.shadow && props.shadowTop);
+const showShadowBottom = computed(() => props.shadow && props.shadowBottom);
+const showShadowLeft = computed(() => props.shadow && props.shadowLeft);
+const showShadowRight = computed(() => props.shadow && props.shadowRight);
+
+const computedShadowClasses = computed(() => ({
+  'shadow-both':
+    !isAtLeft.value &&
+    !isAtRight.value &&
+    showShadowLeft.value &&
+    showShadowRight.value,
+  'shadow-left': !isAtLeft.value && showShadowLeft.value,
+  'shadow-right': !isAtRight.value && showShadowRight.value,
+}));
 
 function handleScroll(event: Event) {
   const target = event.target as HTMLElement;
   const scrollTop = target?.scrollTop ?? 0;
+  const scrollLeft = target?.scrollLeft ?? 0;
   const offsetHeight = target?.offsetHeight ?? 0;
+  const offsetWidth = target?.offsetWidth ?? 0;
   const scrollHeight = target?.scrollHeight ?? 0;
+  const scrollWidth = target?.scrollWidth ?? 0;
   isAtTop.value = scrollTop <= 0;
+  isAtLeft.value = scrollLeft <= 0;
   isAtBottom.value = scrollTop + offsetHeight >= scrollHeight;
+  isAtRight.value = scrollLeft + offsetWidth >= scrollWidth;
+
+  emit('scrollAt', {
+    bottom: isAtBottom.value,
+    left: isAtLeft.value,
+    right: isAtRight.value,
+    top: isAtTop.value,
+  });
 }
 </script>
 
 <template>
   <ScrollArea
-    :class="[cn(props.class)]"
+    :class="[cn(props.class), computedShadowClasses]"
     :on-scroll="handleScroll"
-    class="relative"
+    class="vben-scrollbar relative"
   >
     <div
-      v-if="shadow"
+      v-if="showShadowTop"
       :class="{
         'opacity-100': !isAtTop,
         'border-border border-t': shadowBorder && !isAtTop,
@@ -49,7 +90,7 @@ function handleScroll(event: Event) {
     ></div>
     <slot></slot>
     <div
-      v-if="shadow"
+      v-if="showShadowBottom"
       :class="{
         'opacity-100': !isAtTop && !isAtBottom,
         'border-border border-b': shadowBorder && !isAtTop && !isAtBottom,
@@ -65,6 +106,31 @@ function handleScroll(event: Event) {
 </template>
 
 <style scoped>
+.vben-scrollbar {
+  &:not(.shadow-both).shadow-left {
+    mask-image: linear-gradient(90deg, transparent, #000 16px);
+  }
+
+  &:not(.shadow-both).shadow-right {
+    mask-image: linear-gradient(
+      90deg,
+      #000 0%,
+      #000 calc(100% - 16px),
+      transparent
+    );
+  }
+
+  &.shadow-both {
+    mask-image: linear-gradient(
+      90deg,
+      transparent,
+      #000 16px,
+      #000 calc(100% - 16px),
+      transparent 100%
+    );
+  }
+}
+
 .scrollbar-top-shadow {
   background: linear-gradient(
     to bottom,

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

@@ -41,6 +41,7 @@
     "@vben-core/icons": "workspace:*",
     "@vben-core/shadcn-ui": "workspace:*",
     "@vben-core/typings": "workspace:*",
-    "vue": "^3.4.38"
+    "@vueuse/core": "^11.0.0",
+    "vue": "^3.4.37"
   }
 }

+ 104 - 160
packages/@core/ui-kit/tabs-ui/src/components/tabs-chrome/tabs.vue

@@ -3,10 +3,10 @@ import type { TabDefinition } from '@vben-core/typings';
 
 import type { TabConfig, TabsProps } from '../../types';
 
-import { computed, ref, watch } from 'vue';
+import { computed, ref } from 'vue';
 
 import { MdiPin, X } from '@vben-core/icons';
-import { VbenContextMenu, VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
+import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
 
 interface Props extends TabsProps {}
 
@@ -20,17 +20,17 @@ const props = withDefaults(defineProps<Props>(), {
   contentClass: 'vben-tabs-content',
   contextMenus: () => [],
   gap: 7,
-  maxWidth: 150,
-  minWidth: 80,
   tabs: () => [],
 });
 
-const emit = defineEmits<{ close: [string]; unpin: [TabDefinition] }>();
+const emit = defineEmits<{
+  close: [string];
+  unpin: [TabDefinition];
+}>();
 const active = defineModel<string>('active');
 
 const contentRef = ref();
 const tabRef = ref();
-const tabWidth = ref<number>(props.maxWidth);
 
 const style = computed(() => {
   const { gap } = props;
@@ -53,148 +53,118 @@ const tabsView = computed((): TabConfig[] => {
     };
   });
 });
-
-watch(active, () => {
-  scrollIntoView();
-});
-
-function scrollIntoView() {
-  setTimeout(() => {
-    const element = document.querySelector(`.tabs-chrome__item.is-active`);
-
-    if (element) {
-      element.scrollIntoView({ behavior: 'smooth', block: 'start' });
-    }
-  });
-}
 </script>
 
 <template>
-  <div :style="style" class="tabs-chrome size-full flex-1 overflow-hidden pt-1">
-    <VbenScrollbar
-      id="tabs-scrollbar"
-      class="tabs-chrome__scrollbar h-full"
-      horizontal
-      scroll-bar-class="z-10 hidden"
-    >
-      <!-- footer -> 4px -->
+  <div
+    ref="contentRef"
+    :class="contentClass"
+    :style="style"
+    class="tabs-chrome !flex h-full w-max pr-6"
+  >
+    <TransitionGroup name="slide-left">
       <div
-        ref="contentRef"
-        :class="contentClass"
-        class="relative !flex h-full w-max"
+        v-for="(tab, i) in tabsView"
+        :key="tab.key"
+        ref="tabRef"
+        :class="[{ 'is-active': tab.key === active, dragable: !tab.affixTab }]"
+        :data-active-tab="active"
+        :data-index="i"
+        class="tabs-chrome__item draggable group relative -mr-3 flex h-full select-none items-center"
+        data-tab-item="true"
+        @click="active = tab.key"
       >
-        <TransitionGroup name="slide-left">
-          <div
-            v-for="(tab, i) in tabsView"
-            :key="tab.key"
-            ref="tabRef"
-            :class="[
-              { 'is-active': tab.key === active, dragable: !tab.affixTab },
-            ]"
-            :data-active-tab="active"
-            :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"
+        <VbenContextMenu
+          :handler-data="tab"
+          :menus="contextMenus"
+          :modal="false"
+          item-class="pr-6"
+        >
+          <div class="relative size-full px-1">
+            <!-- divider -->
+            <div
+              v-if="i !== 0 && tab.key !== active"
+              class="tabs-chrome__divider bg-foreground/50 absolute left-[var(--gap)] top-1/2 z-0 h-4 w-[1px] translate-y-[-50%] transition-all"
+            ></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="size-full">
-                <!-- divider -->
-                <div
-                  v-if="i !== 0 && tab.key !== active"
-                  class="tabs-chrome__divider bg-foreground/60 absolute left-[var(--gap)] top-1/2 z-0 h-4 w-[1px] translate-y-[-50%] transition-all"
-                ></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 group-[.is-active]:bg-primary/15 dark:group-[.is-active]:bg-accent h-full rounded-tl-[var(--gap)] rounded-tr-[var(--gap)] duration-150"
-                  ></div>
-                  <svg
-                    class="tabs-chrome__background-before group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 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 group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 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)*1.5)] top-1/2 z-[3] size-4 translate-y-[-50%]"
-                >
-                  <!-- close-icon -->
-                  <X
-                    v-show="
-                      !tab.affixTab && tabsView.length > 1 && tab.closable
-                    "
-                    class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground dark:group-[.is-active]:text-accent-foreground group-[.is-active]:text-primary mt-[2px] size-3 cursor-pointer rounded-full transition-all"
-                    @click.stop="() => emit('close', tab.key)"
-                  />
-                  <MdiPin
-                    v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
-                    class="hover:bg-accent hover:stroke-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
-                    @click.stop="() => emit('unpin', tab)"
-                  />
-                </div>
-
-                <!-- tab-item-main -->
-                <div
-                  class="tabs-chrome__item-main group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground text-accent-foreground 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] pr-4 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 text-sm"
-                  >
-                    {{ tab.title }}
-                  </span>
-                </div>
-              </div>
-            </VbenContextMenu>
+              <div
+                class="tabs-chrome__background-content group-[.is-active]:bg-heavy dark:group-[.is-active]:bg-accent h-full rounded-tl-[var(--gap)] rounded-tr-[var(--gap)] duration-150"
+              ></div>
+              <svg
+                class="tabs-chrome__background-before group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 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 group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 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-[var(--gap)] top-1/2 z-[3] size-4 translate-y-[-50%]"
+            >
+              <!-- close-icon -->
+              <X
+                v-show="!tab.affixTab && tabsView.length > 1 && tab.closable"
+                class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground text-accent-foreground/80 group-[.is-active]:text-accent-foreground mt-[2px] size-3 cursor-pointer rounded-full transition-all"
+                @click.stop="() => emit('close', tab.key)"
+              />
+              <MdiPin
+                v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
+                class="hover:text-accent-foreground text-accent-foreground/80 group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
+                @click.stop="() => emit('unpin', tab)"
+              />
+            </div>
+
+            <!-- tab-item-main -->
+            <div
+              class="tabs-chrome__item-main group-[.is-active]:text-accent-foreground dark:group-[.is-active]:text-accent-foreground text-accent-foreground z-[2] mx-[calc(var(--gap)*2)] my-0 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] pl-2 pr-4 duration-150"
+            >
+              <VbenIcon
+                v-if="showIcon"
+                :icon="tab.icon"
+                class="mr-1 flex size-4 items-center overflow-hidden"
+                fallback
+              />
+
+              <span class="flex-1 overflow-hidden whitespace-nowrap text-sm">
+                {{ tab.title }}
+              </span>
+            </div>
           </div>
-        </TransitionGroup>
+        </VbenContextMenu>
       </div>
-      <!-- footer -->
-      <!-- <div class="bg-background h-1"></div> -->
-    </VbenScrollbar>
+    </TransitionGroup>
   </div>
 </template>
 
 <style scoped>
 .tabs-chrome {
-  .dragging {
-    .tabs-chrome__item-main {
+  /* .dragging { */
+
+  /* .tabs-chrome__item-main {
       @apply pr-0;
-    }
+    } */
 
-    .tabs-chrome__extra {
+  /* .tabs-chrome__extra {
       @apply hidden;
-    }
-  }
+    } */
+
+  /* } */
+
+  &__item:not(.dragging) {
+    @apply cursor-pointer;
 
-  &__item {
     &:hover:not(.is-active) {
       & + .tabs-chrome__item {
         .tabs-chrome__divider {
@@ -207,13 +177,10 @@ function scrollIntoView() {
       }
 
       .tabs-chrome__background {
-        &-content {
-          @apply bg-accent mx-1 rounded-md pb-2;
-        }
+        @apply pb-[2px];
 
-        &-before,
-        &-after {
-          @apply fill-primary/0;
+        &-content {
+          @apply bg-accent-hover mx-[2px] rounded-md;
         }
       }
     }
@@ -226,30 +193,7 @@ function scrollIntoView() {
           @apply opacity-0 !important;
         }
       }
-
-      .tabs-chrome__background {
-        @apply opacity-100;
-
-        /* &-content {
-          @apply bg-accent;
-        }
-
-        &-before,
-        &-after {
-          @apply fill-heavy;
-        } */
-      }
     }
   }
-
-  &__scrollbar,
-  &__label {
-    mask-image: linear-gradient(
-      90deg,
-      #000 0%,
-      #000 calc(100% - 16px),
-      transparent
-    );
-  }
 }
 </style>

+ 64 - 98
packages/@core/ui-kit/tabs-ui/src/components/tabs/tabs.vue

@@ -3,10 +3,10 @@ import type { TabDefinition } from '@vben-core/typings';
 
 import type { TabConfig, TabsProps } from '../../types';
 
-import { computed, watch } from 'vue';
+import { computed } from 'vue';
 
 import { MdiPin, X } from '@vben-core/icons';
-import { VbenContextMenu, VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
+import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
 
 interface Props extends TabsProps {}
 
@@ -21,7 +21,10 @@ const props = withDefaults(defineProps<Props>(), {
   tabs: () => [],
 });
 
-const emit = defineEmits<{ close: [string]; unpin: [TabDefinition] }>();
+const emit = defineEmits<{
+  close: [string];
+  unpin: [TabDefinition];
+}>();
 const active = defineModel<string>('active');
 
 const typeWithClass = computed(() => {
@@ -55,108 +58,71 @@ const tabsView = computed((): TabConfig[] => {
     };
   });
 });
-
-watch(active, () => {
-  scrollIntoView();
-});
-
-function scrollIntoView() {
-  setTimeout(() => {
-    const element = document.querySelector(`.tabs-chrome__item.is-active`);
-
-    if (element) {
-      element.scrollIntoView({ behavior: 'smooth', block: 'start' });
-    }
-  });
-}
 </script>
 
 <template>
-  <div class="size-full flex-1 overflow-hidden">
-    <VbenScrollbar
-      id="tabs-scrollbar"
-      class="tabs-scrollbar h-full"
-      horizontal
-      scroll-bar-class="z-10 hidden"
-    >
+  <div
+    :class="contentClass"
+    class="relative !flex h-full w-max items-center pr-6"
+  >
+    <TransitionGroup name="slide-left">
       <div
-        :class="contentClass"
-        class="relative !flex h-full w-max items-center"
+        v-for="(tab, i) in tabsView"
+        :key="tab.key"
+        :class="[
+          {
+            'is-active dark:bg-accent bg-primary/15': tab.key === active,
+            dragable: !tab.affixTab,
+          },
+          typeWithClass.content,
+        ]"
+        :data-index="i"
+        class="tab-item [&:not(.is-active)]:hover:bg-accent group relative flex cursor-pointer select-none"
+        data-tab-item="true"
+        @click="active = tab.key"
       >
-        <TransitionGroup name="slide-left">
-          <div
-            v-for="(tab, i) in tabsView"
-            :key="tab.key"
-            :class="[
-              {
-                'is-active dark:bg-accent bg-primary/15': tab.key === active,
-                dragable: !tab.affixTab,
-              },
-              typeWithClass.content,
-            ]"
-            :data-index="i"
-            class="tabs-chrome__item [&:not(.is-active)]:hover:bg-accent group relative flex cursor-pointer select-none transition-all duration-300"
-            @click="active = tab.key"
-          >
-            <VbenContextMenu
-              :handler-data="tab"
-              :menus="contextMenus"
-              :modal="false"
-              item-class="pr-6"
+        <VbenContextMenu
+          :handler-data="tab"
+          :menus="contextMenus"
+          :modal="false"
+          item-class="pr-6"
+        >
+          <div class="relative flex size-full items-center">
+            <!-- extra -->
+            <div
+              class="absolute right-1.5 top-1/2 z-[3] translate-y-[-50%] overflow-hidden"
             >
-              <div class="relative flex size-full items-center">
-                <!-- extra -->
-                <div
-                  class="absolute right-1.5 top-1/2 z-[3] translate-y-[-50%] overflow-hidden"
-                >
-                  <!-- close-icon -->
-                  <X
-                    v-show="
-                      !tab.affixTab && tabsView.length > 1 && tab.closable
-                    "
-                    class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground dark:group-[.is-active]:text-accent-foreground group-[.is-active]:text-primary size-3 cursor-pointer rounded-full transition-all"
-                    @click.stop="() => emit('close', tab.key)"
-                  />
-                  <MdiPin
-                    v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
-                    class="hover:bg-accent hover:stroke-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
-                    @click.stop="() => emit('unpin', tab)"
-                  />
-                </div>
-
-                <!-- tab-item-main -->
-                <div
-                  class="text-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mx-3 mr-4 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] pr-3 transition-all duration-300"
-                >
-                  <VbenIcon
-                    v-if="showIcon"
-                    :icon="tab.icon"
-                    class="mr-2 flex size-4 items-center overflow-hidden"
-                    fallback
-                  />
-
-                  <span
-                    class="flex-1 overflow-hidden whitespace-nowrap text-sm"
-                  >
-                    {{ tab.title }}
-                  </span>
-                </div>
-              </div>
-            </VbenContextMenu>
+              <!-- close-icon -->
+              <X
+                v-show="!tab.affixTab && tabsView.length > 1 && tab.closable"
+                class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground dark:group-[.is-active]:text-accent-foreground group-[.is-active]:text-primary size-3 cursor-pointer rounded-full transition-all"
+                @click.stop="() => emit('close', tab.key)"
+              />
+              <MdiPin
+                v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
+                class="hover:bg-accent hover:stroke-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
+                @click.stop="() => emit('unpin', tab)"
+              />
+            </div>
+
+            <!-- tab-item-main -->
+            <div
+              class="text-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mx-3 mr-4 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] pr-3 transition-all duration-300"
+            >
+              <VbenIcon
+                v-if="showIcon"
+                :icon="tab.icon"
+                class="mr-2 flex size-4 items-center overflow-hidden"
+                fallback
+              />
+
+              <span class="flex-1 overflow-hidden whitespace-nowrap text-sm">
+                {{ tab.title }}
+              </span>
+            </div>
           </div>
-        </TransitionGroup>
+        </VbenContextMenu>
       </div>
-    </VbenScrollbar>
+    </TransitionGroup>
   </div>
 </template>
-
-<style scoped>
-.tabs-scrollbar {
-  mask-image: linear-gradient(
-    90deg,
-    #000 0%,
-    #000 calc(100% - 16px),
-    transparent
-  );
-}
-</style>

+ 50 - 120
packages/@core/ui-kit/tabs-ui/src/tabs-view.vue

@@ -1,15 +1,12 @@
 <script setup lang="ts">
-import type { Sortable } from '@vben-core/composables';
-import type { TabDefinition } from '@vben-core/typings';
+import type { TabsEmits, TabsProps } from './types';
 
-import type { TabsProps } from './types';
-
-import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
-
-import { useForwardPropsEmits, useSortable } from '@vben-core/composables';
+import { useForwardPropsEmits } from '@vben-core/composables';
 import { ChevronLeft, ChevronRight } from '@vben-core/icons';
+import { VbenScrollbar } from '@vben-core/shadcn-ui';
 
 import { Tabs, TabsChrome } from './components';
+import { useTabsDrag } from './use-tabs-drag';
 import { useTabsViewScroll } from './use-tabs-view-scroll';
 
 interface Props extends TabsProps {}
@@ -24,136 +21,69 @@ const props = withDefaults(defineProps<Props>(), {
   styleType: 'chrome',
 });
 
-const emit = defineEmits<{
-  close: [string];
-  sortTabs: [number, number];
-  unpin: [TabDefinition];
-}>();
+const emit = defineEmits<TabsEmits>();
 
 const forward = useForwardPropsEmits(props, emit);
 
-const { initScrollbar, scrollDirection } = useTabsViewScroll();
-
-const sortableInstance = ref<null | Sortable>(null);
-
-// 可能会找到拖拽的子元素,这里需要确保拖拽的dom时tab元素
-function 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 resetElState = () => {
-    el.style.cursor = 'default';
-    el.classList.remove('dragging');
-  };
-
-  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) {
-        resetElState();
-        return;
-      }
-
-      const srcParent = findParentElement(srcElement);
-
-      if (!srcParent) {
-        resetElState();
-        return;
-      }
+const {
+  handleScrollAt,
+  scrollbarRef,
+  scrollDirection,
+  scrollIsAtLeft,
+  scrollIsAtRight,
+  showScrollButton,
+} = useTabsViewScroll(props);
 
-      if (!srcParent.classList.contains('dragable')) {
-        resetElState();
-
-        return;
-      }
-
-      if (
-        oldIndex !== undefined &&
-        newIndex !== undefined &&
-        !Number.isNaN(oldIndex) &&
-        !Number.isNaN(newIndex) &&
-        oldIndex !== newIndex
-      ) {
-        emit('sortTabs', oldIndex, newIndex);
-      }
-      resetElState();
-    },
-    onMove(evt) {
-      const parent = findParentElement(evt.related);
-      return parent?.classList.contains('dragable') && props.dragable;
-    },
-    onStart: () => {
-      el.style.cursor = 'grabbing';
-      el.classList.add('dragging');
-    },
-  });
-
-  sortableInstance.value = await initializeSortable();
-}
-
-async function init() {
-  await nextTick();
-  initTabsSortable();
-  initScrollbar();
-}
-
-onMounted(() => {
-  init();
-});
-
-watch(
-  () => props.styleType,
-  () => {
-    sortableInstance.value?.destroy();
-    init();
-  },
-);
-
-onUnmounted(() => {
-  sortableInstance.value?.destroy();
-});
+useTabsDrag(props, emit);
 </script>
 
 <template>
-  <div
-    :class="{
-      'overflow-hidden': styleType !== 'chrome',
-    }"
-    class="flex h-full flex-1"
-  >
+  <div class="flex h-full flex-1 overflow-hidden">
     <!-- 左侧滚动按钮 -->
     <span
-      class="hover:bg-muted text-muted-foreground cursor-pointer border-r px-2"
+      v-show="showScrollButton"
+      :class="{
+        'hover:bg-muted text-muted-foreground cursor-pointer': !scrollIsAtLeft,
+        'pointer-events-none opacity-30': scrollIsAtLeft,
+      }"
+      class="border-r px-2"
       @click="scrollDirection('left')"
     >
       <ChevronLeft class="size-4 h-full" />
     </span>
 
-    <TabsChrome
-      v-if="styleType === 'chrome'"
-      v-bind="{ ...forward, ...$attrs, ...$props }"
-    />
-    <Tabs v-else v-bind="{ ...forward, ...$attrs, ...$props }" />
+    <div
+      :class="{
+        'pt-[3px]': styleType === 'chrome',
+      }"
+      class="size-full flex-1 overflow-hidden"
+    >
+      <VbenScrollbar
+        ref="scrollbarRef"
+        class="h-full"
+        horizontal
+        scroll-bar-class="z-10 hidden"
+        shadow
+        shadow-left
+        shadow-right
+        @scroll-at="handleScrollAt"
+      >
+        <TabsChrome
+          v-if="styleType === 'chrome'"
+          v-bind="{ ...forward, ...$attrs, ...$props }"
+        />
+
+        <Tabs v-else v-bind="{ ...forward, ...$attrs, ...$props }" />
+      </VbenScrollbar>
+    </div>
 
     <!-- 左侧滚动按钮 -->
     <span
+      v-show="showScrollButton"
+      :class="{
+        'hover:bg-muted text-muted-foreground cursor-pointer': !scrollIsAtRight,
+        'pointer-events-none opacity-30': scrollIsAtRight,
+      }"
       class="hover:bg-muted text-muted-foreground cursor-pointer border-l px-2"
       @click="scrollDirection('right')"
     >

+ 9 - 4
packages/@core/ui-kit/tabs-ui/src/types.ts

@@ -1,7 +1,14 @@
 import type { IContextMenuItem } from '@vben-core/shadcn-ui';
 import type { TabDefinition, TabsStyleType } from '@vben-core/typings';
 
-interface TabsProps {
+export type TabsEmits = {
+  close: [string];
+  sortTabs: [number, number];
+  unpin: [TabDefinition];
+};
+
+export interface TabsProps {
+  active?: string;
   /**
    * @zh_CN content class
    * @default tabs-chrome
@@ -48,12 +55,10 @@ interface TabsProps {
   tabs?: TabDefinition[];
 }
 
-interface TabConfig extends TabDefinition {
+export interface TabConfig extends TabDefinition {
   affixTab: boolean;
   closable: boolean;
   icon: string;
   key: string;
   title: string;
 }
-
-export type { TabConfig, TabsProps };

+ 110 - 0
packages/@core/ui-kit/tabs-ui/src/use-tabs-drag.ts

@@ -0,0 +1,110 @@
+import type { EmitType } from '@vben-core/typings';
+
+import type { TabsProps } from './types';
+
+import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
+
+import { type Sortable, useSortable } from '@vben-core/composables';
+
+// 可能会找到拖拽的子元素,这里需要确保拖拽的dom时tab元素
+function findParentElement(element: HTMLElement) {
+  const parentCls = 'group';
+  return element.classList.contains(parentCls)
+    ? element
+    : element.closest(`.${parentCls}`);
+}
+
+export function useTabsDrag(props: TabsProps, emit: EmitType) {
+  const sortableInstance = ref<null | Sortable>(null);
+
+  async function initTabsSortable() {
+    await nextTick();
+
+    const el = document.querySelectorAll(
+      `.${props.contentClass}`,
+    )?.[0] as HTMLElement;
+
+    if (!el) {
+      console.warn('Element not found for sortable initialization');
+      return;
+    }
+
+    const resetElState = async () => {
+      el.style.cursor = 'default';
+      el.classList.remove('dragging');
+      el.querySelector('.draggable')?.classList.remove('dragging');
+    };
+
+    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) {
+          resetElState();
+          return;
+        }
+
+        const srcParent = findParentElement(srcElement);
+
+        if (!srcParent) {
+          resetElState();
+          return;
+        }
+
+        if (!srcParent.classList.contains('dragable')) {
+          resetElState();
+
+          return;
+        }
+
+        if (
+          oldIndex !== undefined &&
+          newIndex !== undefined &&
+          !Number.isNaN(oldIndex) &&
+          !Number.isNaN(newIndex) &&
+          oldIndex !== newIndex
+        ) {
+          emit('sortTabs', oldIndex, newIndex);
+        }
+        resetElState();
+      },
+      onMove(evt) {
+        const parent = findParentElement(evt.related);
+        return parent?.classList.contains('dragable') && props.dragable;
+      },
+      onStart: () => {
+        el.style.cursor = 'grabbing';
+        el.querySelector('.draggable')?.classList.add('dragging');
+        // el.classList.add('dragging');
+      },
+    });
+
+    sortableInstance.value = await initializeSortable();
+  }
+
+  async function init() {
+    await nextTick();
+    initTabsSortable();
+  }
+
+  onMounted(init);
+
+  watch(
+    () => props.styleType,
+    () => {
+      sortableInstance.value?.destroy();
+      init();
+    },
+  );
+
+  onUnmounted(() => {
+    sortableInstance.value?.destroy();
+  });
+}

+ 147 - 13
packages/@core/ui-kit/tabs-ui/src/use-tabs-view-scroll.ts

@@ -1,15 +1,28 @@
-import { nextTick, ref } from 'vue';
+import type { TabsProps } from './types';
 
-type El = Element | null | undefined;
+import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
 
-export function useTabsViewScroll(scrollDistance: number = 150) {
-  const scrollbarEl = ref<El>(null);
-  const scrollViewportEl = ref<El>(null);
+import { VbenScrollbar } from '@vben-core/shadcn-ui';
+
+import { useDebounceFn } from '@vueuse/core';
+
+type DomElement = Element | null | undefined;
+
+export function useTabsViewScroll(props: TabsProps) {
+  let resizeObserver: null | ResizeObserver = null;
+  let mutationObserver: MutationObserver | null = null;
+  let tabItemCount = 0;
+  const scrollbarRef = ref<InstanceType<typeof VbenScrollbar> | null>(null);
+  const scrollViewportEl = ref<DomElement>(null);
+  const showScrollButton = ref(false);
+  const scrollIsAtLeft = ref(true);
+  const scrollIsAtRight = ref(false);
 
   function getScrollClientWidth() {
-    if (!scrollbarEl.value || !scrollViewportEl.value) return {};
+    const scrollbarEl = scrollbarRef.value?.$el;
+    if (!scrollbarEl || !scrollViewportEl.value) return {};
 
-    const scrollbarWidth = scrollbarEl.value.clientWidth;
+    const scrollbarWidth = scrollbarEl.clientWidth;
     const scrollViewWidth = scrollViewportEl.value.clientWidth;
 
     return {
@@ -20,7 +33,7 @@ export function useTabsViewScroll(scrollDistance: number = 150) {
 
   function scrollDirection(
     direction: 'left' | 'right',
-    distance: number = scrollDistance,
+    distance: number = 150,
   ) {
     const { scrollbarWidth, scrollViewWidth } = getScrollClientWidth();
 
@@ -39,21 +52,142 @@ export function useTabsViewScroll(scrollDistance: number = 150) {
 
   async function initScrollbar() {
     await nextTick();
-    const barEl = document.querySelector('#tabs-scrollbar');
 
-    const viewportEl = barEl?.querySelector(
+    const scrollbarEl = scrollbarRef.value?.$el;
+    if (!scrollbarEl) {
+      return;
+    }
+
+    const viewportEl = scrollbarEl?.querySelector(
       'div[data-radix-scroll-area-viewport]',
     );
 
-    scrollbarEl.value = barEl;
     scrollViewportEl.value = viewportEl;
+    calcShowScrollbarButton();
+
+    await nextTick();
+    scrollToActiveIntoView();
+
+    // 监听大小变化
+    resizeObserver?.disconnect();
+    resizeObserver = new ResizeObserver(
+      useDebounceFn((_entries: ResizeObserverEntry[]) => {
+        calcShowScrollbarButton();
+      }, 100),
+    );
+    resizeObserver.observe(viewportEl);
 
-    const activeItem = viewportEl?.querySelector('.is-active');
-    activeItem?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    tabItemCount = props.tabs?.length || 0;
+    mutationObserver?.disconnect();
+    // 使用 MutationObserver 仅监听子节点数量变化
+    mutationObserver = new MutationObserver(() => {
+      const count = viewportEl.querySelectorAll(
+        `div[data-tab-item="true"]`,
+      ).length;
+
+      if (count > tabItemCount) {
+        scrollToActiveIntoView();
+      }
+
+      if (count !== tabItemCount) {
+        calcShowScrollbarButton();
+        tabItemCount = count;
+      }
+    });
+
+    // 配置为仅监听子节点的添加和移除
+    mutationObserver.observe(viewportEl, {
+      attributes: false,
+      childList: true,
+      subtree: true,
+    });
   }
 
+  async function scrollToActiveIntoView() {
+    if (!scrollViewportEl.value) {
+      return;
+    }
+    await nextTick();
+    const viewportEl = scrollViewportEl.value;
+    const { scrollbarWidth } = getScrollClientWidth();
+    const { scrollWidth } = viewportEl;
+
+    if (scrollbarWidth >= scrollWidth) {
+      return;
+    }
+
+    requestAnimationFrame(() => {
+      const activeItem = viewportEl?.querySelector('.is-active');
+      activeItem?.scrollIntoView({ behavior: 'smooth', inline: 'start' });
+    });
+  }
+
+  /**
+   * 计算tabs 宽度,用于判断是否显示左右滚动按钮
+   */
+  async function calcShowScrollbarButton() {
+    if (!scrollViewportEl.value) {
+      return;
+    }
+
+    const { scrollbarWidth } = getScrollClientWidth();
+
+    showScrollButton.value =
+      scrollViewportEl.value.scrollWidth > scrollbarWidth;
+  }
+
+  const handleScrollAt = useDebounceFn(({ left, right }) => {
+    scrollIsAtLeft.value = left;
+    scrollIsAtRight.value = right;
+  }, 100);
+
+  watch(
+    () => props.active,
+    async () => {
+      // 200为了等待 tab 切换动画完成
+      // setTimeout(() => {
+      scrollToActiveIntoView();
+      // }, 300);
+    },
+    {
+      flush: 'post',
+    },
+  );
+
+  // watch(
+  //   () => props.tabs?.length,
+  //   async () => {
+  //     await nextTick();
+  //     calcShowScrollbarButton();
+  //   },
+  //   {
+  //     flush: 'post',
+  //   },
+  // );
+
+  watch(
+    () => props.styleType,
+    () => {
+      initScrollbar();
+    },
+  );
+
+  onMounted(initScrollbar);
+
+  onUnmounted(() => {
+    resizeObserver?.disconnect();
+    mutationObserver?.disconnect();
+    resizeObserver = null;
+    mutationObserver = null;
+  });
+
   return {
+    handleScrollAt,
     initScrollbar,
+    scrollbarRef,
     scrollDirection,
+    scrollIsAtLeft,
+    scrollIsAtRight,
+    showScrollButton,
   };
 }

+ 1 - 1
packages/effects/access/package.json

@@ -24,6 +24,6 @@
     "@vben/stores": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
-    "vue": "^3.4.38"
+    "vue": "^3.4.37"
   }
 }

+ 2 - 2
packages/effects/chart-ui/package.json

@@ -21,8 +21,8 @@
   },
   "dependencies": {
     "@vben/preferences": "workspace:*",
-    "@vueuse/core": "^10.11.1",
+    "@vueuse/core": "^11.0.0",
     "echarts": "^5.5.1",
-    "vue": "^3.4.38"
+    "vue": "^3.4.37"
   }
 }

+ 2 - 2
packages/effects/common-ui/package.json

@@ -26,9 +26,9 @@
     "@vben/icons": "workspace:*",
     "@vben/locales": "workspace:*",
     "@vben/types": "workspace:*",
-    "@vueuse/integrations": "^10.11.1",
+    "@vueuse/integrations": "^11.0.0",
     "qrcode": "^1.5.4",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   },
   "devDependencies": {

+ 1 - 1
packages/effects/hooks/package.json

@@ -25,7 +25,7 @@
     "@vben/stores": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3",
     "watermark-js-plus": "^1.5.3"
   }

+ 2 - 2
packages/effects/layouts/package.json

@@ -32,8 +32,8 @@
     "@vben/stores": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
-    "@vueuse/core": "^10.11.1",
-    "vue": "^3.4.38",
+    "@vueuse/core": "^11.0.0",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   }
 }

+ 1 - 1
packages/locales/package.json

@@ -21,7 +21,7 @@
   },
   "dependencies": {
     "@intlify/core-base": "^9.13.1",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-i18n": "^9.13.1"
   }
 }

+ 1 - 1
packages/stores/package.json

@@ -24,7 +24,7 @@
     "@vben-core/typings": "workspace:*",
     "pinia": "2.2.2",
     "pinia-plugin-persistedstate": "^3.2.1",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   }
 }

+ 14 - 3
packages/stores/src/modules/tabbar.ts

@@ -124,10 +124,21 @@ export const useTabbarStore = defineStore('core-tabbar', {
       } else {
         // 页面已经存在,不重复添加选项卡,只更新选项卡参数
         const currentTab = toRaw(this.tabs)[tabIndex];
-        const mergedTab = { ...currentTab, ...tab };
-        if (currentTab && Reflect.has(currentTab.meta, 'affixTab')) {
-          mergedTab.meta.affixTab = currentTab.meta.affixTab;
+        const mergedTab = {
+          ...currentTab,
+          ...tab,
+          meta: { ...currentTab?.meta, ...tab.meta },
+        };
+        if (currentTab) {
+          const curMeta = currentTab.meta;
+          if (Reflect.has(curMeta, 'affixTab')) {
+            mergedTab.meta.affixTab = curMeta.affixTab;
+          }
+          if (Reflect.has(curMeta, 'newTabTitle')) {
+            mergedTab.meta.newTabTitle = curMeta.newTabTitle;
+          }
         }
+
         this.tabs.splice(tabIndex, 1, mergedTab);
       }
       this.updateCacheTab();

+ 1 - 1
packages/types/package.json

@@ -21,7 +21,7 @@
   },
   "dependencies": {
     "@vben-core/typings": "workspace:*",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   }
 }

+ 2 - 2
playground/package.json

@@ -40,11 +40,11 @@
     "@vben/styles": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
-    "@vueuse/core": "^10.11.1",
+    "@vueuse/core": "^11.0.0",
     "ant-design-vue": "^4.2.3",
     "dayjs": "^1.11.12",
     "pinia": "2.2.2",
-    "vue": "^3.4.38",
+    "vue": "^3.4.37",
     "vue-router": "^4.4.3"
   }
 }

+ 1 - 1
playground/src/router/routes/modules/demos.ts

@@ -161,7 +161,7 @@ const routes: RouteRecordRaw[] = [
                   import(
                     '#/views/demos/features/hide-menu-children/children.vue'
                   ),
-                meta: { title: 'HideChildrenInMenuChildrenDemo' },
+                meta: { title: $t('page.demos.features.hideChildrenInMenu') },
               },
             ],
           },

+ 73 - 42
pnpm-lock.yaml

@@ -165,8 +165,8 @@ importers:
         specifier: workspace:*
         version: link:../../packages/utils
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       ant-design-vue:
         specifier: ^4.2.3
         version: 4.2.3(vue@3.4.38(typescript@5.5.4))
@@ -228,8 +228,8 @@ importers:
         specifier: workspace:*
         version: link:../../packages/utils
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       dayjs:
         specifier: ^1.11.12
         version: 1.11.12
@@ -295,8 +295,8 @@ importers:
         specifier: workspace:*
         version: link:../../packages/utils
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       naive-ui:
         specifier: ^2.39.0
         version: 2.39.0(vue@3.4.38(typescript@5.5.4))
@@ -660,8 +660,8 @@ importers:
         specifier: ^4.1.2
         version: 4.1.2(vue@3.4.38(typescript@5.5.4))
       lucide-vue-next:
-        specifier: ^0.427.0
-        version: 0.427.0(vue@3.4.38(typescript@5.5.4))
+        specifier: ^0.428.0
+        version: 0.428.0(vue@3.4.38(typescript@5.5.4))
       vue:
         specifier: 3.4.38
         version: 3.4.38(typescript@5.5.4)
@@ -672,7 +672,7 @@ importers:
         specifier: 4.1.0
         version: 4.1.0
       '@vue/shared':
-        specifier: ^3.4.38
+        specifier: ^3.4.37
         version: 3.4.38
       clsx:
         specifier: 2.1.1
@@ -715,8 +715,8 @@ importers:
         specifier: workspace:*
         version: link:../base/shared
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       radix-vue:
         specifier: ^1.9.4
         version: 1.9.4(vue@3.4.38(typescript@5.5.4))
@@ -740,8 +740,8 @@ importers:
         specifier: workspace:*
         version: link:../base/typings
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       vue:
         specifier: 3.4.38
         version: 3.4.38(typescript@5.5.4)
@@ -761,8 +761,8 @@ importers:
         specifier: workspace:*
         version: link:../../base/typings
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       vue:
         specifier: 3.4.38
         version: 3.4.38(typescript@5.5.4)
@@ -785,8 +785,8 @@ importers:
         specifier: workspace:*
         version: link:../../base/typings
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       vue:
         specifier: 3.4.38
         version: 3.4.38(typescript@5.5.4)
@@ -806,14 +806,14 @@ importers:
         specifier: workspace:*
         version: link:../../base/typings
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       class-variance-authority:
         specifier: ^0.7.0
         version: 0.7.0
       lucide-vue-next:
-        specifier: ^0.427.0
-        version: 0.427.0(vue@3.4.38(typescript@5.5.4))
+        specifier: ^0.428.0
+        version: 0.428.0(vue@3.4.38(typescript@5.5.4))
       radix-vue:
         specifier: ^1.9.4
         version: 1.9.4(vue@3.4.38(typescript@5.5.4))
@@ -835,6 +835,9 @@ importers:
       '@vben-core/typings':
         specifier: workspace:*
         version: link:../../base/typings
+      '@vueuse/core':
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       vue:
         specifier: 3.4.38
         version: 3.4.38(typescript@5.5.4)
@@ -869,8 +872,8 @@ importers:
         specifier: workspace:*
         version: link:../../preferences
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       echarts:
         specifier: ^5.5.1
         version: 5.5.1
@@ -899,8 +902,8 @@ importers:
         specifier: workspace:*
         version: link:../../types
       '@vueuse/integrations':
-        specifier: ^10.11.1
-        version: 10.11.1(async-validator@4.2.5)(axios@1.7.4)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.2)(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(async-validator@4.2.5)(axios@1.7.4)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.2)(vue@3.4.38(typescript@5.5.4))
       qrcode:
         specifier: ^1.5.4
         version: 1.5.4
@@ -981,8 +984,8 @@ importers:
         specifier: workspace:*
         version: link:../../utils
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       vue:
         specifier: 3.4.38
         version: 3.4.38(typescript@5.5.4)
@@ -1129,8 +1132,8 @@ importers:
         specifier: workspace:*
         version: link:../packages/utils
       '@vueuse/core':
-        specifier: ^10.11.1
-        version: 10.11.1(vue@3.4.38(typescript@5.5.4))
+        specifier: ^11.0.0
+        version: 11.0.0(vue@3.4.38(typescript@5.5.4))
       ant-design-vue:
         specifier: ^4.2.3
         version: 4.2.3(vue@3.4.38(typescript@5.5.4))
@@ -4179,6 +4182,9 @@ packages:
   '@vueuse/core@10.11.1':
     resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
 
+  '@vueuse/core@11.0.0':
+    resolution: {integrity: sha512-shibzNGjmRjZucEm97B8V0NO5J3vPHMCE/mltxQ3vHezbDoFQBMtK11XsfwfPionxSbo+buqPmsCljtYuXIBpw==}
+
   '@vueuse/core@9.13.0':
     resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
 
@@ -4223,21 +4229,21 @@ packages:
       universal-cookie:
         optional: true
 
-  '@vueuse/integrations@10.11.1':
-    resolution: {integrity: sha512-Y5hCGBguN+vuVYTZmdd/IMXLOdfS60zAmDmFYc4BKBcMUPZH1n4tdyDECCPjXm0bNT3ZRUy1xzTLGaUje8Xyaw==}
+  '@vueuse/integrations@11.0.0':
+    resolution: {integrity: sha512-B95nBX4B2q2ZETBDldrKARM/fYXBHfwdo44UbHBq4bUTi25lrlc8MwAZGqEoRvdV4ND9T6O1Rb9e4kaCJFXnqw==}
     peerDependencies:
       async-validator: ^4
       axios: ^1
-      change-case: ^4
-      drauu: ^0.3
+      change-case: ^5
+      drauu: ^0.4
       focus-trap: ^7
-      fuse.js: ^6
+      fuse.js: ^7
       idb-keyval: ^6
-      jwt-decode: ^3
+      jwt-decode: ^4
       nprogress: ^0.2
       qrcode: ^1.5
       sortablejs: ^1
-      universal-cookie: ^6
+      universal-cookie: ^7
     peerDependenciesMeta:
       async-validator:
         optional: true
@@ -4270,6 +4276,9 @@ packages:
   '@vueuse/metadata@10.11.1':
     resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
 
+  '@vueuse/metadata@11.0.0':
+    resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
+
   '@vueuse/metadata@9.13.0':
     resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
 
@@ -4279,6 +4288,9 @@ packages:
   '@vueuse/shared@10.11.1':
     resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
 
+  '@vueuse/shared@11.0.0':
+    resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
+
   '@vueuse/shared@9.13.0':
     resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
 
@@ -6916,8 +6928,8 @@ packages:
     resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
     engines: {node: '>=10'}
 
-  lucide-vue-next@0.427.0:
-    resolution: {integrity: sha512-zI1FhbfQ3Wl0SgPKnOWhTDC6yAC5TTjSC9FSZ61ULg3U36e+GVK+RT1qfkU9Q5BjeBuwmsHWKsXKptKMjUAwFA==}
+  lucide-vue-next@0.428.0:
+    resolution: {integrity: sha512-of9GJGus9VKGIUOp3yQ0uQtNv+8MRLaso8H4OiDzI6+T7TeMRXTzqVOLhnyg9fdXUnYuwE9Xm1zD1nfQ7oFPmg==}
     peerDependencies:
       vue: 3.4.38
 
@@ -13196,6 +13208,16 @@ snapshots:
       - '@vue/composition-api'
       - vue
 
+  '@vueuse/core@11.0.0(vue@3.4.38(typescript@5.5.4))':
+    dependencies:
+      '@types/web-bluetooth': 0.0.20
+      '@vueuse/metadata': 11.0.0
+      '@vueuse/shared': 11.0.0(vue@3.4.38(typescript@5.5.4))
+      vue-demi: 0.14.10(vue@3.4.38(typescript@5.5.4))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
   '@vueuse/core@9.13.0(vue@3.4.38(typescript@5.5.4))':
     dependencies:
       '@types/web-bluetooth': 0.0.16
@@ -13222,10 +13244,10 @@ snapshots:
       - '@vue/composition-api'
       - vue
 
-  '@vueuse/integrations@10.11.1(async-validator@4.2.5)(axios@1.7.4)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.2)(vue@3.4.38(typescript@5.5.4))':
+  '@vueuse/integrations@11.0.0(async-validator@4.2.5)(axios@1.7.4)(focus-trap@7.5.4)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.2)(vue@3.4.38(typescript@5.5.4))':
     dependencies:
-      '@vueuse/core': 10.11.1(vue@3.4.38(typescript@5.5.4))
-      '@vueuse/shared': 10.11.1(vue@3.4.38(typescript@5.5.4))
+      '@vueuse/core': 11.0.0(vue@3.4.38(typescript@5.5.4))
+      '@vueuse/shared': 11.0.0(vue@3.4.38(typescript@5.5.4))
       vue-demi: 0.14.10(vue@3.4.38(typescript@5.5.4))
     optionalDependencies:
       async-validator: 4.2.5
@@ -13242,6 +13264,8 @@ snapshots:
 
   '@vueuse/metadata@10.11.1': {}
 
+  '@vueuse/metadata@11.0.0': {}
+
   '@vueuse/metadata@9.13.0': {}
 
   '@vueuse/shared@10.11.0(vue@3.4.38(typescript@5.5.4))':
@@ -13258,6 +13282,13 @@ snapshots:
       - '@vue/composition-api'
       - vue
 
+  '@vueuse/shared@11.0.0(vue@3.4.38(typescript@5.5.4))':
+    dependencies:
+      vue-demi: 0.14.10(vue@3.4.38(typescript@5.5.4))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
   '@vueuse/shared@9.13.0(vue@3.4.38(typescript@5.5.4))':
     dependencies:
       vue-demi: 0.14.10(vue@3.4.38(typescript@5.5.4))
@@ -16250,7 +16281,7 @@ snapshots:
     dependencies:
       yallist: 4.0.0
 
-  lucide-vue-next@0.427.0(vue@3.4.38(typescript@5.5.4)):
+  lucide-vue-next@0.428.0(vue@3.4.38(typescript@5.5.4)):
     dependencies:
       vue: 3.4.38(typescript@5.5.4)