mirror of
				https://github.com/soybeanjs/soybean-admin.git
				synced 2025-11-04 15:53:43 +08:00 
			
		
		
		
	feat(projects): 迁移多页签
This commit is contained in:
		@@ -75,18 +75,18 @@ const routes: AuthRoute.Route[] = [
 | 
			
		||||
  }
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const routeHome: AuthRoute.RoutePath = '/dashboard/analysis';
 | 
			
		||||
function dataMiddleware(data: AuthRoute.Route[]): ApiRoute.Route {
 | 
			
		||||
  const routeHomeName: AuthRoute.RouteKey = 'dashboard_analysis';
 | 
			
		||||
 | 
			
		||||
function sortRoutes() {
 | 
			
		||||
  routes.sort((next, pre) => Number(next.meta?.order) - Number(pre.meta?.order));
 | 
			
		||||
  function sortRoutes(sorts: AuthRoute.Route[]) {
 | 
			
		||||
    return sorts.sort((next, pre) => Number(next.meta?.order) - Number(pre.meta?.order));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
sortRoutes();
 | 
			
		||||
 | 
			
		||||
const data: ApiRoute.Route = {
 | 
			
		||||
  routes,
 | 
			
		||||
  home: routeHome
 | 
			
		||||
  return {
 | 
			
		||||
    routes: sortRoutes(data),
 | 
			
		||||
    home: routeHomeName
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const apis: MockMethod[] = [
 | 
			
		||||
  {
 | 
			
		||||
@@ -96,7 +96,7 @@ const apis: MockMethod[] = [
 | 
			
		||||
      return {
 | 
			
		||||
        code: 200,
 | 
			
		||||
        message: 'ok',
 | 
			
		||||
        data
 | 
			
		||||
        data: dataMiddleware(routes)
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/custom/BetterScroll/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/custom/BetterScroll/index.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div ref="bsWrap" class="h-full text-left">
 | 
			
		||||
    <div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
 | 
			
		||||
      <slot></slot>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, computed, watch, onMounted } from 'vue';
 | 
			
		||||
import { useElementSize } from '@vueuse/core';
 | 
			
		||||
import BScroll from '@better-scroll/core';
 | 
			
		||||
import type { Options } from '@better-scroll/core';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  /** better-scroll的配置: https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html */
 | 
			
		||||
  options: Options;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = defineProps<Props>();
 | 
			
		||||
 | 
			
		||||
const bsWrap = ref<HTMLElement>();
 | 
			
		||||
const instance = ref<BScroll>();
 | 
			
		||||
const bsContent = ref<HTMLElement>();
 | 
			
		||||
const isScrollY = computed(() => Boolean(props.options.scrollY));
 | 
			
		||||
 | 
			
		||||
function initBetterScroll() {
 | 
			
		||||
  if (!bsWrap.value) return;
 | 
			
		||||
  instance.value = new BScroll(bsWrap.value, props.options);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 滚动元素发生变化,刷新BS
 | 
			
		||||
const { width, height } = useElementSize(bsContent);
 | 
			
		||||
watch([() => width.value, () => height.value], () => {
 | 
			
		||||
  if (instance.value) {
 | 
			
		||||
    instance.value.refresh();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  initBetterScroll();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
defineExpose({ instance });
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
							
								
								
									
										71
									
								
								src/components/custom/ButtonTab/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/components/custom/ButtonTab/index.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    class="relative flex-center h-30px pl-14px border-1px border-[#e5e7eb] dark:border-[#ffffff3d] rounded-2px cursor-pointer transition-colors duration-300 ease-in-out"
 | 
			
		||||
    :class="[closable ? 'pr-6px' : 'pr-14px']"
 | 
			
		||||
    :style="buttonStyle"
 | 
			
		||||
    @mouseenter="setTrue"
 | 
			
		||||
    @mouseleave="setFalse"
 | 
			
		||||
  >
 | 
			
		||||
    <span class="whitespace-nowrap">
 | 
			
		||||
      <slot></slot>
 | 
			
		||||
    </span>
 | 
			
		||||
    <div v-if="closable" class="pl-10px">
 | 
			
		||||
      <icon-close :is-active="isIconActive" :primary-color="primaryColor" @click="handleClose" />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed } from 'vue';
 | 
			
		||||
import { useBoolean } from '@/hooks';
 | 
			
		||||
import { addColorAlpha } from '@/utils';
 | 
			
		||||
import IconClose from '../IconClose/index.vue';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  /** 激活状态 */
 | 
			
		||||
  isActive?: boolean;
 | 
			
		||||
  /** 主题颜色 */
 | 
			
		||||
  primaryColor?: string;
 | 
			
		||||
  /** 是否显示关闭图标 */
 | 
			
		||||
  closable?: boolean;
 | 
			
		||||
  /** 暗黑模式 */
 | 
			
		||||
  darkMode?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Emits {
 | 
			
		||||
  /** 点击关闭图标 */
 | 
			
		||||
  (e: 'close'): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<Props>(), {
 | 
			
		||||
  isActive: false,
 | 
			
		||||
  primaryColor: '#1890ff',
 | 
			
		||||
  closable: true,
 | 
			
		||||
  darkMode: false
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<Emits>();
 | 
			
		||||
 | 
			
		||||
const { bool: isHover, setTrue, setFalse } = useBoolean();
 | 
			
		||||
 | 
			
		||||
const isIconActive = computed(() => props.isActive || isHover.value);
 | 
			
		||||
 | 
			
		||||
const buttonStyle = computed(() => {
 | 
			
		||||
  const style: { [key: string]: string } = {};
 | 
			
		||||
  if (isIconActive.value) {
 | 
			
		||||
    style.color = props.primaryColor;
 | 
			
		||||
    style.borderColor = addColorAlpha(props.primaryColor, 0.3);
 | 
			
		||||
    if (props.isActive) {
 | 
			
		||||
      const alpha = props.darkMode ? 0.15 : 0.1;
 | 
			
		||||
      style.backgroundColor = addColorAlpha(props.primaryColor, alpha);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return style;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function handleClose(e: MouseEvent) {
 | 
			
		||||
  e.stopPropagation();
 | 
			
		||||
  emit('close');
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
							
								
								
									
										79
									
								
								src/components/custom/ChromeTab/components/SvgRadiusBg.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/components/custom/ChromeTab/components/SvgRadiusBg.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <svg>
 | 
			
		||||
    <defs>
 | 
			
		||||
      <symbol id="geometry-left" viewBox="0 0 214 36">
 | 
			
		||||
        <path d="M17 0h197v36H0v-2c4.5 0 9-3.5 9-8V8c0-4.5 3.5-8 8-8z"></path>
 | 
			
		||||
      </symbol>
 | 
			
		||||
      <symbol id="geometry-right" viewBox="0 0 214 36">
 | 
			
		||||
        <use xlink:href="#geometry-left"></use>
 | 
			
		||||
      </symbol>
 | 
			
		||||
      <clipPath>
 | 
			
		||||
        <rect width="100%" height="100%" x="0"></rect>
 | 
			
		||||
      </clipPath>
 | 
			
		||||
    </defs>
 | 
			
		||||
    <svg width="52%" height="100%">
 | 
			
		||||
      <use
 | 
			
		||||
        xlink:href="#geometry-left"
 | 
			
		||||
        width="214"
 | 
			
		||||
        height="36"
 | 
			
		||||
        :fill="fill"
 | 
			
		||||
        class="transition-fill duration-300 ease-in-out"
 | 
			
		||||
      ></use>
 | 
			
		||||
    </svg>
 | 
			
		||||
    <g transform="scale(-1, 1)">
 | 
			
		||||
      <svg width="52%" height="100%" x="-100%" y="0">
 | 
			
		||||
        <use
 | 
			
		||||
          xlink:href="#geometry-right"
 | 
			
		||||
          width="214"
 | 
			
		||||
          height="36"
 | 
			
		||||
          :fill="fill"
 | 
			
		||||
          class="transition-fill duration-300 ease-in-out"
 | 
			
		||||
        ></use>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </g>
 | 
			
		||||
  </svg>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { computed } from 'vue';
 | 
			
		||||
import { mixColor } from '@/utils';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  /** 激活状态 */
 | 
			
		||||
  isActive?: boolean;
 | 
			
		||||
  /** 鼠标悬浮状态 */
 | 
			
		||||
  isHover?: boolean;
 | 
			
		||||
  /** 主题颜色 */
 | 
			
		||||
  primaryColor?: string;
 | 
			
		||||
  /** 暗黑模式 */
 | 
			
		||||
  darkMode?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 填充的背景颜色: [默认颜色, 暗黑主题颜色] */
 | 
			
		||||
type FillColor = [string, string];
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<Props>(), {
 | 
			
		||||
  isActive: false,
 | 
			
		||||
  isHover: false,
 | 
			
		||||
  primaryColor: '#409EFF',
 | 
			
		||||
  darkMode: false
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const defaultColor: FillColor = ['#fff', '#18181c'];
 | 
			
		||||
const hoverColor: FillColor = ['#dee1e6', '#3f3c37'];
 | 
			
		||||
const mixColors: FillColor = ['#ffffff', '#000000'];
 | 
			
		||||
 | 
			
		||||
const fill = computed(() => {
 | 
			
		||||
  const index = Number(props.darkMode);
 | 
			
		||||
  let color = defaultColor[index];
 | 
			
		||||
  if (props.isHover) {
 | 
			
		||||
    color = hoverColor[index];
 | 
			
		||||
  }
 | 
			
		||||
  if (props.isActive) {
 | 
			
		||||
    const alpha = props.darkMode ? 0.1 : 0.15;
 | 
			
		||||
    color = mixColor(mixColors[index], props.primaryColor, alpha);
 | 
			
		||||
  }
 | 
			
		||||
  return color;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
							
								
								
									
										3
									
								
								src/components/custom/ChromeTab/components/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/components/custom/ChromeTab/components/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import SvgRadiusBg from './SvgRadiusBg.vue';
 | 
			
		||||
 | 
			
		||||
export { SvgRadiusBg };
 | 
			
		||||
							
								
								
									
										66
									
								
								src/components/custom/ChromeTab/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/components/custom/ChromeTab/index.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    class="relative flex-y-center h-34px px-24px -mr-18px cursor-pointer"
 | 
			
		||||
    :class="{ 'z-10': isActive, 'z-9': isHover }"
 | 
			
		||||
    @mouseenter="setTrue"
 | 
			
		||||
    @mouseleave="setFalse"
 | 
			
		||||
  >
 | 
			
		||||
    <div class="absolute-lb wh-full overflow-hidden">
 | 
			
		||||
      <svg-radius-bg
 | 
			
		||||
        class="wh-full"
 | 
			
		||||
        :is-active="isActive"
 | 
			
		||||
        :is-hover="isHover"
 | 
			
		||||
        :dark-mode="darkMode"
 | 
			
		||||
        :primary-color="primaryColor"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
    <span class="relative whitespace-nowrap z-2">
 | 
			
		||||
      <slot></slot>
 | 
			
		||||
    </span>
 | 
			
		||||
    <div v-if="closable" class="pl-18px">
 | 
			
		||||
      <icon-close :is-active="isActive" :primary-color="primaryColor" @click="handleClose" />
 | 
			
		||||
    </div>
 | 
			
		||||
    <n-divider v-if="!isHover && !isActive" :vertical="true" class="absolute right-0 !bg-[#a4abb8] z-2" />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { NDivider } from 'naive-ui';
 | 
			
		||||
import { useBoolean } from '@/hooks';
 | 
			
		||||
import IconClose from '../IconClose/index.vue';
 | 
			
		||||
import { SvgRadiusBg } from './components';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  /** 激活状态 */
 | 
			
		||||
  isActive?: boolean;
 | 
			
		||||
  /** 主题颜色 */
 | 
			
		||||
  primaryColor?: string;
 | 
			
		||||
  /** 是否显示关闭图标 */
 | 
			
		||||
  closable?: boolean;
 | 
			
		||||
  /** 暗黑模式 */
 | 
			
		||||
  darkMode?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Emits {
 | 
			
		||||
  /** 点击关闭图标 */
 | 
			
		||||
  (e: 'close'): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
withDefaults(defineProps<Props>(), {
 | 
			
		||||
  isActive: false,
 | 
			
		||||
  primaryColor: '#409EFF',
 | 
			
		||||
  closable: true,
 | 
			
		||||
  darkMode: false,
 | 
			
		||||
  isLast: false
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<Emits>();
 | 
			
		||||
 | 
			
		||||
const { bool: isHover, setTrue, setFalse } = useBoolean();
 | 
			
		||||
 | 
			
		||||
function handleClose(e: MouseEvent) {
 | 
			
		||||
  e.stopPropagation();
 | 
			
		||||
  emit('close');
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
							
								
								
									
										35
									
								
								src/components/custom/IconClose/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/components/custom/IconClose/index.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    class="relative flex-center w-18px h-18px text-14px"
 | 
			
		||||
    :style="{ color: isActive ? primaryColor : defaultColor }"
 | 
			
		||||
    @mouseenter="setTrue"
 | 
			
		||||
    @mouseleave="setFalse"
 | 
			
		||||
  >
 | 
			
		||||
    <transition name="fade">
 | 
			
		||||
      <icon-mdi:close-circle v-if="isHover" key="hover" class="absolute" />
 | 
			
		||||
      <icon-mdi:close v-else key="unhover" class="absolute" />
 | 
			
		||||
    </transition>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { useBoolean } from '@/hooks';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  /** 激活状态 */
 | 
			
		||||
  isActive?: boolean;
 | 
			
		||||
  /** 主题颜色 */
 | 
			
		||||
  primaryColor?: string;
 | 
			
		||||
  /** 默认颜色 */
 | 
			
		||||
  defaultColor?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
withDefaults(defineProps<Props>(), {
 | 
			
		||||
  isPrimary: false,
 | 
			
		||||
  primaryColor: '#1890ff',
 | 
			
		||||
  defaultColor: '#9ca3af'
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const { bool: isHover, setTrue, setFalse } = useBoolean();
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
@@ -1,4 +1,7 @@
 | 
			
		||||
import BetterScroll from './BetterScroll/index.vue';
 | 
			
		||||
import ButtonTab from './ButtonTab/index.vue';
 | 
			
		||||
import ChromeTab from './ChromeTab/index.vue';
 | 
			
		||||
import CountTo from './CountTo/index.vue';
 | 
			
		||||
import ImageVerify from './ImageVerify/index.vue';
 | 
			
		||||
 | 
			
		||||
export { CountTo, ImageVerify };
 | 
			
		||||
export { BetterScroll, ButtonTab, ChromeTab, CountTo, ImageVerify };
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,7 @@ export enum EnumStorageKey {
 | 
			
		||||
  /** 用户刷新token */
 | 
			
		||||
  'refresh-koken' = '__REFRESH_TOKEN__',
 | 
			
		||||
  /** 用户信息 */
 | 
			
		||||
  'user-info' = '__USER_INFO__'
 | 
			
		||||
  'user-info' = '__USER_INFO__',
 | 
			
		||||
  /** 多页签路由信息 */
 | 
			
		||||
  'tab-routes' = '__TAB_ROUTES__'
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
export interface ExposeLayoutMixMenu {
 | 
			
		||||
  resetFirstDegreeMenus(): void;
 | 
			
		||||
import BScroll from '@better-scroll/core';
 | 
			
		||||
 | 
			
		||||
export interface ExposeBetterScroll {
 | 
			
		||||
  instance: BScroll;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import type { VNodeChild } from 'vue';
 | 
			
		||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
 | 
			
		||||
import type { DropdownOption } from 'naive-ui';
 | 
			
		||||
 | 
			
		||||
/** 菜单项配置 */
 | 
			
		||||
@@ -20,3 +21,6 @@ export type GlobalBreadcrumb = DropdownOption & {
 | 
			
		||||
  hasChildren: boolean;
 | 
			
		||||
  children?: GlobalBreadcrumb[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** 多页签Tab的路由 */
 | 
			
		||||
export type GlobalTabRoute = Pick<RouteLocationNormalizedLoaded, 'name' | 'path' | 'meta'>;
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <hover-container class="w-64px h-full" tooltip-content="重新加载" placement="bottom-end" @click="handleRefresh">
 | 
			
		||||
    <icon-mdi-refresh class="text-18px" :class="{ 'animate-spin': loading }" />
 | 
			
		||||
  </hover-container>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { HoverContainer } from '@/components';
 | 
			
		||||
import { useAppStore } from '@/store';
 | 
			
		||||
import { useLoading } from '@/hooks';
 | 
			
		||||
 | 
			
		||||
const app = useAppStore();
 | 
			
		||||
const { loading, startLoading, endLoading } = useLoading();
 | 
			
		||||
 | 
			
		||||
function handleRefresh() {
 | 
			
		||||
  startLoading();
 | 
			
		||||
  app.reloadPage();
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    endLoading();
 | 
			
		||||
  }, 1000);
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
@@ -0,0 +1,135 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <n-dropdown
 | 
			
		||||
    :show="dropdownVisible"
 | 
			
		||||
    :options="options"
 | 
			
		||||
    placement="bottom-start"
 | 
			
		||||
    :x="x"
 | 
			
		||||
    :y="y"
 | 
			
		||||
    @clickoutside="hide"
 | 
			
		||||
    @select="handleDropdown"
 | 
			
		||||
  />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { computed } from 'vue';
 | 
			
		||||
import { NDropdown } from 'naive-ui';
 | 
			
		||||
import type { DropdownOption } from 'naive-ui';
 | 
			
		||||
import { useAppStore, useTabStore } from '@/store';
 | 
			
		||||
import { iconifyRender } from '@/utils';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  /** 右键菜单可见性 */
 | 
			
		||||
  visible?: boolean;
 | 
			
		||||
  /** 当前路由路径 */
 | 
			
		||||
  currentPath?: string;
 | 
			
		||||
  /** 鼠标x坐标 */
 | 
			
		||||
  x: number;
 | 
			
		||||
  /** 鼠标y坐标 */
 | 
			
		||||
  y: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Emits {
 | 
			
		||||
  (e: 'update:visible', visible: boolean): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DropdownKey = 'reload-current' | 'close-current' | 'close-other' | 'close-left' | 'close-right' | 'close-all';
 | 
			
		||||
type Option = DropdownOption & {
 | 
			
		||||
  key: DropdownKey;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<Props>(), {
 | 
			
		||||
  visible: false,
 | 
			
		||||
  currentPath: ''
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<Emits>();
 | 
			
		||||
 | 
			
		||||
const app = useAppStore();
 | 
			
		||||
const tab = useTabStore();
 | 
			
		||||
 | 
			
		||||
const dropdownVisible = computed({
 | 
			
		||||
  get() {
 | 
			
		||||
    return props.visible;
 | 
			
		||||
  },
 | 
			
		||||
  set(visible: boolean) {
 | 
			
		||||
    emit('update:visible', visible);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function hide() {
 | 
			
		||||
  dropdownVisible.value = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const options = computed<Option[]>(() => [
 | 
			
		||||
  {
 | 
			
		||||
    label: '重新加载',
 | 
			
		||||
    key: 'reload-current',
 | 
			
		||||
    disabled: props.currentPath !== tab.activeTab,
 | 
			
		||||
    icon: iconifyRender('ant-design:reload-outlined')
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    label: '关闭',
 | 
			
		||||
    key: 'close-current',
 | 
			
		||||
    disabled: props.currentPath === tab.homeTab.path,
 | 
			
		||||
    icon: iconifyRender('ant-design:close-outlined')
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    label: '关闭其他',
 | 
			
		||||
    key: 'close-other',
 | 
			
		||||
    icon: iconifyRender('ant-design:column-width-outlined')
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    label: '关闭左侧',
 | 
			
		||||
    key: 'close-left',
 | 
			
		||||
    icon: iconifyRender('mdi:format-horizontal-align-left')
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    label: '关闭右侧',
 | 
			
		||||
    key: 'close-right',
 | 
			
		||||
    icon: iconifyRender('mdi:format-horizontal-align-right')
 | 
			
		||||
  }
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
const actionMap = new Map<DropdownKey, () => void>([
 | 
			
		||||
  [
 | 
			
		||||
    'reload-current',
 | 
			
		||||
    () => {
 | 
			
		||||
      app.reloadPage();
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  [
 | 
			
		||||
    'close-current',
 | 
			
		||||
    () => {
 | 
			
		||||
      tab.removeTab(props.currentPath);
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  [
 | 
			
		||||
    'close-other',
 | 
			
		||||
    () => {
 | 
			
		||||
      tab.clearTab([props.currentPath]);
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  [
 | 
			
		||||
    'close-left',
 | 
			
		||||
    () => {
 | 
			
		||||
      tab.clearLeftTab(props.currentPath);
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  [
 | 
			
		||||
    'close-right',
 | 
			
		||||
    () => {
 | 
			
		||||
      tab.clearRightTab(props.currentPath);
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
function handleDropdown(optionKey: string) {
 | 
			
		||||
  const key = optionKey as DropdownKey;
 | 
			
		||||
  const actionFunc = actionMap.get(key);
 | 
			
		||||
  if (actionFunc) {
 | 
			
		||||
    actionFunc();
 | 
			
		||||
  }
 | 
			
		||||
  hide();
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
import ContextMenu from './ContextMenu.vue';
 | 
			
		||||
 | 
			
		||||
export { ContextMenu };
 | 
			
		||||
							
								
								
									
										102
									
								
								src/layouts/common/GlobalTab/components/TabDetail/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/layouts/common/GlobalTab/components/TabDetail/index.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,102 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div ref="tabRef" class="flex items-end h-full" :class="[isChromeMode ? 'flex items-end' : 'flex-y-center']">
 | 
			
		||||
    <component
 | 
			
		||||
      :is="activeComponent"
 | 
			
		||||
      v-for="(item, index) in tab.tabs"
 | 
			
		||||
      :key="item.path"
 | 
			
		||||
      :is-active="tab.activeTab === item.path"
 | 
			
		||||
      :primary-color="theme.themeColor"
 | 
			
		||||
      :closable="item.path !== tab.homeTab.path"
 | 
			
		||||
      :dark-mode="theme.darkMode"
 | 
			
		||||
      :class="{ '!mr-0': isChromeMode && index === tab.tabs.length - 1, 'mr-10px': !isChromeMode }"
 | 
			
		||||
      @click="tab.handleClickTab(item.path)"
 | 
			
		||||
      @close="tab.removeTab(item.path)"
 | 
			
		||||
      @contextmenu="handleContextMenu($event, item.path)"
 | 
			
		||||
    >
 | 
			
		||||
      {{ item.meta.title }}
 | 
			
		||||
    </component>
 | 
			
		||||
  </div>
 | 
			
		||||
  <context-menu
 | 
			
		||||
    v-model:visible="dropdown.visible"
 | 
			
		||||
    :current-path="dropdown.currentPath"
 | 
			
		||||
    :x="dropdown.x"
 | 
			
		||||
    :y="dropdown.y"
 | 
			
		||||
  />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, reactive, computed, nextTick, watch } from 'vue';
 | 
			
		||||
import { useEventListener } from '@vueuse/core';
 | 
			
		||||
import { ChromeTab, ButtonTab } from '@/components';
 | 
			
		||||
import { useThemeStore, useTabStore } from '@/store';
 | 
			
		||||
import { setTabRoutes } from '@/utils';
 | 
			
		||||
import { ContextMenu } from './components';
 | 
			
		||||
 | 
			
		||||
interface Emits {
 | 
			
		||||
  (e: 'scroll', clientX: number): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<Emits>();
 | 
			
		||||
 | 
			
		||||
const theme = useThemeStore();
 | 
			
		||||
const tab = useTabStore();
 | 
			
		||||
 | 
			
		||||
const isChromeMode = computed(() => theme.tab.mode === 'chrome');
 | 
			
		||||
const activeComponent = computed(() => (isChromeMode.value ? ChromeTab : ButtonTab));
 | 
			
		||||
 | 
			
		||||
// 获取当前激活的tab的clientX
 | 
			
		||||
const tabRef = ref<HTMLElement>();
 | 
			
		||||
async function getActiveTabClientX() {
 | 
			
		||||
  await nextTick();
 | 
			
		||||
  if (tabRef.value) {
 | 
			
		||||
    const activeTabElement = tabRef.value.children[tab.activeTabIndex];
 | 
			
		||||
    const { x, width } = activeTabElement.getBoundingClientRect();
 | 
			
		||||
    const clientX = x + width / 2;
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      emit('scroll', clientX);
 | 
			
		||||
    }, 50);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const dropdown = reactive({
 | 
			
		||||
  visible: false,
 | 
			
		||||
  x: 0,
 | 
			
		||||
  y: 0,
 | 
			
		||||
  currentPath: ''
 | 
			
		||||
});
 | 
			
		||||
function showDropdown() {
 | 
			
		||||
  dropdown.visible = true;
 | 
			
		||||
}
 | 
			
		||||
function hideDropdown() {
 | 
			
		||||
  dropdown.visible = false;
 | 
			
		||||
}
 | 
			
		||||
function setDropdown(x: number, y: number, currentPath: string) {
 | 
			
		||||
  Object.assign(dropdown, { x, y, currentPath });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 点击右键菜单 */
 | 
			
		||||
async function handleContextMenu(e: MouseEvent, path: string) {
 | 
			
		||||
  e.preventDefault();
 | 
			
		||||
  const { clientX, clientY } = e;
 | 
			
		||||
  hideDropdown();
 | 
			
		||||
  setDropdown(clientX, clientY, path);
 | 
			
		||||
  await nextTick();
 | 
			
		||||
  showDropdown();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => tab.activeTabIndex,
 | 
			
		||||
  () => {
 | 
			
		||||
    getActiveTabClientX();
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    immediate: true
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
/** 页面离开时缓存多页签数据 */
 | 
			
		||||
useEventListener(window, 'beforeunload', () => {
 | 
			
		||||
  setTabRoutes(tab.tabs);
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
							
								
								
									
										4
									
								
								src/layouts/common/GlobalTab/components/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/layouts/common/GlobalTab/components/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
import TabDetail from './TabDetail/index.vue';
 | 
			
		||||
import ReloadButton from './ReloadButton/index.vue';
 | 
			
		||||
 | 
			
		||||
export { TabDetail, ReloadButton };
 | 
			
		||||
@@ -1,9 +1,60 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <dark-mode-container class="global-tab flex-y-center h-full"></dark-mode-container>
 | 
			
		||||
  <dark-mode-container class="global-tab flex-y-center w-full pl-16px" :style="{ height: theme.tab.height + 'px' }">
 | 
			
		||||
    <div ref="bsWrapper" class="flex-1-hidden h-full">
 | 
			
		||||
      <better-scroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: isMobile }">
 | 
			
		||||
        <tab-detail @scroll="handleScroll" />
 | 
			
		||||
      </better-scroll>
 | 
			
		||||
    </div>
 | 
			
		||||
    <reload-button />
 | 
			
		||||
  </dark-mode-container>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { DarkModeContainer } from '@/components';
 | 
			
		||||
import { ref, watch } from 'vue';
 | 
			
		||||
import { useRoute } from 'vue-router';
 | 
			
		||||
import { useElementBounding } from '@vueuse/core';
 | 
			
		||||
import { DarkModeContainer, BetterScroll } from '@/components';
 | 
			
		||||
import { useThemeStore, useTabStore } from '@/store';
 | 
			
		||||
import { useIsMobile } from '@/composables';
 | 
			
		||||
import type { ExposeBetterScroll } from '@/interface';
 | 
			
		||||
import { TabDetail, ReloadButton } from './components';
 | 
			
		||||
 | 
			
		||||
const route = useRoute();
 | 
			
		||||
const theme = useThemeStore();
 | 
			
		||||
const tab = useTabStore();
 | 
			
		||||
 | 
			
		||||
const bsWrapper = ref<HTMLElement>();
 | 
			
		||||
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapper);
 | 
			
		||||
 | 
			
		||||
const bsScroll = ref<ExposeBetterScroll>();
 | 
			
		||||
 | 
			
		||||
const isMobile = useIsMobile();
 | 
			
		||||
 | 
			
		||||
function handleScroll(clientX: number) {
 | 
			
		||||
  const currentX = clientX - bsWrapperLeft.value;
 | 
			
		||||
  const deltaX = currentX - bsWrapperWidth.value / 2;
 | 
			
		||||
  if (bsScroll.value) {
 | 
			
		||||
    const { maxScrollX, x: leftX } = bsScroll.value.instance;
 | 
			
		||||
    const rightX = maxScrollX - leftX;
 | 
			
		||||
    const update = deltaX > 0 ? Math.max(-deltaX, rightX) : Math.min(-deltaX, -leftX);
 | 
			
		||||
    bsScroll.value?.instance.scrollBy(update, 0, 300);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function init() {
 | 
			
		||||
  tab.iniTabStore(route);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => route.path,
 | 
			
		||||
  () => {
 | 
			
		||||
    tab.addTab(route);
 | 
			
		||||
    tab.setActiveTab(route.path);
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// 初始化
 | 
			
		||||
init();
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped>
 | 
			
		||||
.global-tab {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
export * from './app';
 | 
			
		||||
export * from './theme';
 | 
			
		||||
export * from './auth';
 | 
			
		||||
export * from './tab';
 | 
			
		||||
export * from './route';
 | 
			
		||||
 
 | 
			
		||||
@@ -3,10 +3,13 @@ import { defineStore } from 'pinia';
 | 
			
		||||
import { fetchUserRoutes } from '@/service';
 | 
			
		||||
import { transformAuthRouteToMenu, transformAuthRoutesToVueRoutes } from '@/utils';
 | 
			
		||||
import type { GlobalMenuOption } from '@/interface';
 | 
			
		||||
import { useTabStore } from '../tab';
 | 
			
		||||
 | 
			
		||||
interface RouteState {
 | 
			
		||||
  /** 是否添加过动态路由 */
 | 
			
		||||
  isAddedDynamicRoute: boolean;
 | 
			
		||||
  /** 路由首页name */
 | 
			
		||||
  routeHomeName: AuthRoute.RouteKey;
 | 
			
		||||
  /** 菜单 */
 | 
			
		||||
  menus: GlobalMenuOption[];
 | 
			
		||||
}
 | 
			
		||||
@@ -14,6 +17,7 @@ interface RouteState {
 | 
			
		||||
export const useRouteStore = defineStore('route-store', {
 | 
			
		||||
  state: (): RouteState => ({
 | 
			
		||||
    isAddedDynamicRoute: false,
 | 
			
		||||
    routeHomeName: 'dashboard_analysis',
 | 
			
		||||
    menus: []
 | 
			
		||||
  }),
 | 
			
		||||
  actions: {
 | 
			
		||||
@@ -22,8 +26,11 @@ export const useRouteStore = defineStore('route-store', {
 | 
			
		||||
     * @param router - 路由实例
 | 
			
		||||
     */
 | 
			
		||||
    async initDynamicRoute(router: Router) {
 | 
			
		||||
      const { initHomeTab } = useTabStore();
 | 
			
		||||
 | 
			
		||||
      const { data } = await fetchUserRoutes();
 | 
			
		||||
      if (data) {
 | 
			
		||||
        this.routeHomeName = data.home;
 | 
			
		||||
        this.menus = transformAuthRouteToMenu(data.routes);
 | 
			
		||||
 | 
			
		||||
        const vueRoutes = transformAuthRoutesToVueRoutes(data.routes);
 | 
			
		||||
@@ -31,6 +38,7 @@ export const useRouteStore = defineStore('route-store', {
 | 
			
		||||
          router.addRoute(route);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        initHomeTab(data.home, router);
 | 
			
		||||
        this.isAddedDynamicRoute = true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										33
									
								
								src/store/modules/tab/helpers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/store/modules/tab/helpers.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
import type { RouteRecordNormalized, RouteLocationNormalizedLoaded } from 'vue-router';
 | 
			
		||||
import type { GlobalTabRoute } from '@/interface';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 *	根据vue路由获取tab路由
 | 
			
		||||
 * @param route
 | 
			
		||||
 */
 | 
			
		||||
export function getTabRouteByVueRoute(route: RouteRecordNormalized | RouteLocationNormalizedLoaded) {
 | 
			
		||||
  const tabRoute: GlobalTabRoute = {
 | 
			
		||||
    name: route.name,
 | 
			
		||||
    path: route.path,
 | 
			
		||||
    meta: route.meta
 | 
			
		||||
  };
 | 
			
		||||
  return tabRoute;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 获取该页签在多页签数据中的索引
 | 
			
		||||
 * @param tabs - 多页签数据
 | 
			
		||||
 * @param path - 该页签的路径
 | 
			
		||||
 */
 | 
			
		||||
export function getIndexInTabRoutes(tabs: GlobalTabRoute[], path: string) {
 | 
			
		||||
  return tabs.findIndex(tab => tab.path === path);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 判断该页签是否在多页签数据中
 | 
			
		||||
 * @param tabs - 多页签数据
 | 
			
		||||
 * @param path - 该页签的路径
 | 
			
		||||
 */
 | 
			
		||||
export function isInTabRoutes(tabs: GlobalTabRoute[], path: string) {
 | 
			
		||||
  return getIndexInTabRoutes(tabs, path) > -1;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										153
									
								
								src/store/modules/tab/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								src/store/modules/tab/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,153 @@
 | 
			
		||||
import type { Router, RouteLocationNormalizedLoaded } from 'vue-router';
 | 
			
		||||
import { defineStore } from 'pinia';
 | 
			
		||||
import { useRouterPush } from '@/composables';
 | 
			
		||||
import { getTabRoutes } from '@/utils';
 | 
			
		||||
import type { GlobalTabRoute } from '@/interface';
 | 
			
		||||
import { useThemeStore } from '../theme';
 | 
			
		||||
import { getTabRouteByVueRoute, isInTabRoutes, getIndexInTabRoutes } from './helpers';
 | 
			
		||||
 | 
			
		||||
interface TabState {
 | 
			
		||||
  /** 多页签数据 */
 | 
			
		||||
  tabs: GlobalTabRoute[];
 | 
			
		||||
  /** 多页签首页 */
 | 
			
		||||
  homeTab: GlobalTabRoute;
 | 
			
		||||
  /** 当前激活状态的页签(路由path) */
 | 
			
		||||
  activeTab: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useTabStore = defineStore('tab-store', {
 | 
			
		||||
  state: (): TabState => ({
 | 
			
		||||
    tabs: [],
 | 
			
		||||
    homeTab: {
 | 
			
		||||
      name: 'root',
 | 
			
		||||
      path: '/',
 | 
			
		||||
      meta: {
 | 
			
		||||
        title: 'root'
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    activeTab: ''
 | 
			
		||||
  }),
 | 
			
		||||
  getters: {
 | 
			
		||||
    /** 当前激活状态的页签索引 */
 | 
			
		||||
    activeTabIndex(state) {
 | 
			
		||||
      const { tabs, activeTab } = state;
 | 
			
		||||
      return tabs.findIndex(tab => tab.path === activeTab);
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  actions: {
 | 
			
		||||
    /**
 | 
			
		||||
     * 设置当前路由对应的页签为激活状态
 | 
			
		||||
     * @param path - 路由path
 | 
			
		||||
     */
 | 
			
		||||
    setActiveTab(path: string) {
 | 
			
		||||
      this.activeTab = path;
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * 初始化首页页签路由
 | 
			
		||||
     * @param routeHomeName - 路由首页的name
 | 
			
		||||
     * @param router - 路由实例
 | 
			
		||||
     */
 | 
			
		||||
    initHomeTab(routeHomeName: string, router: Router) {
 | 
			
		||||
      const routes = router.getRoutes();
 | 
			
		||||
      const findHome = routes.find(item => item.name === routeHomeName);
 | 
			
		||||
      if (findHome) {
 | 
			
		||||
        this.homeTab = getTabRouteByVueRoute(findHome);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * 添加多页签
 | 
			
		||||
     * @param route - 路由
 | 
			
		||||
     */
 | 
			
		||||
    addTab(route: RouteLocationNormalizedLoaded) {
 | 
			
		||||
      if (!isInTabRoutes(this.tabs, route.path)) {
 | 
			
		||||
        this.tabs.push(getTabRouteByVueRoute(route));
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * 删除多页签
 | 
			
		||||
     * @param path - 路由path
 | 
			
		||||
     */
 | 
			
		||||
    removeTab(path: string) {
 | 
			
		||||
      const { routerPush } = useRouterPush(false);
 | 
			
		||||
 | 
			
		||||
      const isActive = this.activeTab === path;
 | 
			
		||||
      const updateTabs = this.tabs.filter(tab => tab.path !== path);
 | 
			
		||||
      this.tabs = updateTabs;
 | 
			
		||||
      if (isActive && updateTabs.length) {
 | 
			
		||||
        const activePath = updateTabs[updateTabs.length - 1].path;
 | 
			
		||||
        this.setActiveTab(activePath);
 | 
			
		||||
        routerPush(activePath);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * 清空多页签(多页签首页保留)
 | 
			
		||||
     * @param excludes - 保留的多页签path
 | 
			
		||||
     */
 | 
			
		||||
    clearTab(excludes: string[] = []) {
 | 
			
		||||
      const { routerPush } = useRouterPush(false);
 | 
			
		||||
 | 
			
		||||
      const homePath = this.homeTab.path;
 | 
			
		||||
      const remain = [homePath, ...excludes];
 | 
			
		||||
      const hasActive = remain.includes(this.activeTab);
 | 
			
		||||
      const updateTabs = this.tabs.filter(tab => remain.includes(tab.path));
 | 
			
		||||
      this.tabs = updateTabs;
 | 
			
		||||
      if (!hasActive && updateTabs.length) {
 | 
			
		||||
        const activePath = updateTabs[updateTabs.length - 1].path;
 | 
			
		||||
        this.setActiveTab(activePath);
 | 
			
		||||
        routerPush(activePath);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * 清除左边多页签
 | 
			
		||||
     * @param path - 路由path
 | 
			
		||||
     */
 | 
			
		||||
    clearLeftTab(path: string) {
 | 
			
		||||
      const index = getIndexInTabRoutes(this.tabs, path);
 | 
			
		||||
      if (index > -1) {
 | 
			
		||||
        const excludes = this.tabs.slice(index).map(item => item.path);
 | 
			
		||||
        this.clearTab(excludes);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * 清除右边多页签
 | 
			
		||||
     * @param path - 路由path
 | 
			
		||||
     */
 | 
			
		||||
    clearRightTab(path: string) {
 | 
			
		||||
      const index = getIndexInTabRoutes(this.tabs, path);
 | 
			
		||||
      if (index > -1) {
 | 
			
		||||
        const excludes = this.tabs.slice(0, index + 1).map(item => item.path);
 | 
			
		||||
        this.clearTab(excludes);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    /**
 | 
			
		||||
     * 点击单个tab
 | 
			
		||||
     * @param path - 路由path
 | 
			
		||||
     */
 | 
			
		||||
    handleClickTab(path: string) {
 | 
			
		||||
      const { routerPush } = useRouterPush(false);
 | 
			
		||||
 | 
			
		||||
      const isActive = this.activeTab === path;
 | 
			
		||||
      if (!isActive) {
 | 
			
		||||
        this.setActiveTab(path);
 | 
			
		||||
        routerPush(path);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    /** 初始化Tab状态 */
 | 
			
		||||
    iniTabStore(currentRoute: RouteLocationNormalizedLoaded) {
 | 
			
		||||
      const theme = useThemeStore();
 | 
			
		||||
 | 
			
		||||
      const isHome = currentRoute.path === this.homeTab.path;
 | 
			
		||||
      const tabs: GlobalTabRoute[] = theme.tab.isCache ? getTabRoutes() : [];
 | 
			
		||||
      const hasHome = isInTabRoutes(tabs, this.homeTab.path);
 | 
			
		||||
      const hasCurrent = isInTabRoutes(tabs, currentRoute.path);
 | 
			
		||||
      if (!hasHome) {
 | 
			
		||||
        tabs.unshift(this.homeTab);
 | 
			
		||||
      }
 | 
			
		||||
      if (!isHome && !hasCurrent) {
 | 
			
		||||
        tabs.push(getTabRouteByVueRoute(currentRoute));
 | 
			
		||||
      }
 | 
			
		||||
      this.tabs = tabs;
 | 
			
		||||
      this.setActiveTab(currentRoute.path);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										2
									
								
								src/typings/api/route.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/typings/api/route.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -5,6 +5,6 @@ declare namespace ApiRoute {
 | 
			
		||||
    /** 动态路由 */
 | 
			
		||||
    routes: AuthRoute.Route[];
 | 
			
		||||
    /** 路由首页对应的key */
 | 
			
		||||
    home: AuthRoute.RoutePath;
 | 
			
		||||
    home: AuthRoute.RouteKey;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
export * from './helpers';
 | 
			
		||||
export * from './menu';
 | 
			
		||||
export * from './breadcrumb';
 | 
			
		||||
export * from './tab';
 | 
			
		||||
export * from './regexp';
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										23
									
								
								src/utils/router/tab.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/utils/router/tab.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
import { EnumStorageKey } from '@/enum';
 | 
			
		||||
import type { GlobalTabRoute } from '@/interface';
 | 
			
		||||
import { setLocal, getLocal } from '../storage';
 | 
			
		||||
 | 
			
		||||
/** 缓存多页签数据 */
 | 
			
		||||
export function setTabRoutes(data: GlobalTabRoute[]) {
 | 
			
		||||
  setLocal(EnumStorageKey['tab-routes'], data);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 获取缓存的多页签数据 */
 | 
			
		||||
export function getTabRoutes() {
 | 
			
		||||
  const routes: GlobalTabRoute[] = [];
 | 
			
		||||
  const data = getLocal<GlobalTabRoute[]>(EnumStorageKey['tab-routes']);
 | 
			
		||||
  if (data) {
 | 
			
		||||
    routes.push(...data);
 | 
			
		||||
  }
 | 
			
		||||
  return routes;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 清空多页签数据 */
 | 
			
		||||
export function clearTabRoutes() {
 | 
			
		||||
  setTabRoutes([]);
 | 
			
		||||
}
 | 
			
		||||
@@ -126,4 +126,4 @@ onMounted(() => {
 | 
			
		||||
  renderPieChart();
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
<style scoped></style>
 | 
			
		||||
<style></style>
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ export default defineConfig(configEnv => {
 | 
			
		||||
    css: {
 | 
			
		||||
      preprocessorOptions: {
 | 
			
		||||
        scss: {
 | 
			
		||||
          additionalData: `@use "./src/styles/scss/global.scss" as *;`
 | 
			
		||||
          additionalData: `@use "${fileURLToPath(new URL('./src', import.meta.url))}/styles/scss/global.scss" as *;`
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user