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,19 +75,19 @@ 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() {
 | 
					  function sortRoutes(sorts: AuthRoute.Route[]) {
 | 
				
			||||||
  routes.sort((next, pre) => Number(next.meta?.order) - Number(pre.meta?.order));
 | 
					    return sorts.sort((next, pre) => Number(next.meta?.order) - Number(pre.meta?.order));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    routes: sortRoutes(data),
 | 
				
			||||||
 | 
					    home: routeHomeName
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
sortRoutes();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const data: ApiRoute.Route = {
 | 
					 | 
				
			||||||
  routes,
 | 
					 | 
				
			||||||
  home: routeHome
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const apis: MockMethod[] = [
 | 
					const apis: MockMethod[] = [
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    url: '/mock/getUserRoutes',
 | 
					    url: '/mock/getUserRoutes',
 | 
				
			||||||
@@ -96,7 +96,7 @@ const apis: MockMethod[] = [
 | 
				
			|||||||
      return {
 | 
					      return {
 | 
				
			||||||
        code: 200,
 | 
					        code: 200,
 | 
				
			||||||
        message: 'ok',
 | 
					        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 CountTo from './CountTo/index.vue';
 | 
				
			||||||
import ImageVerify from './ImageVerify/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 */
 | 
					  /** 用户刷新token */
 | 
				
			||||||
  'refresh-koken' = '__REFRESH_TOKEN__',
 | 
					  'refresh-koken' = '__REFRESH_TOKEN__',
 | 
				
			||||||
  /** 用户信息 */
 | 
					  /** 用户信息 */
 | 
				
			||||||
  'user-info' = '__USER_INFO__'
 | 
					  'user-info' = '__USER_INFO__',
 | 
				
			||||||
 | 
					  /** 多页签路由信息 */
 | 
				
			||||||
 | 
					  'tab-routes' = '__TAB_ROUTES__'
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,5 @@
 | 
				
			|||||||
export interface ExposeLayoutMixMenu {
 | 
					import BScroll from '@better-scroll/core';
 | 
				
			||||||
  resetFirstDegreeMenus(): void;
 | 
					
 | 
				
			||||||
 | 
					export interface ExposeBetterScroll {
 | 
				
			||||||
 | 
					  instance: BScroll;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
import type { VNodeChild } from 'vue';
 | 
					import type { VNodeChild } from 'vue';
 | 
				
			||||||
 | 
					import type { RouteLocationNormalizedLoaded } from 'vue-router';
 | 
				
			||||||
import type { DropdownOption } from 'naive-ui';
 | 
					import type { DropdownOption } from 'naive-ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** 菜单项配置 */
 | 
					/** 菜单项配置 */
 | 
				
			||||||
@@ -20,3 +21,6 @@ export type GlobalBreadcrumb = DropdownOption & {
 | 
				
			|||||||
  hasChildren: boolean;
 | 
					  hasChildren: boolean;
 | 
				
			||||||
  children?: GlobalBreadcrumb[];
 | 
					  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>
 | 
					<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>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup lang="ts">
 | 
					<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>
 | 
					</script>
 | 
				
			||||||
<style scoped>
 | 
					<style scoped>
 | 
				
			||||||
.global-tab {
 | 
					.global-tab {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
export * from './app';
 | 
					export * from './app';
 | 
				
			||||||
export * from './theme';
 | 
					export * from './theme';
 | 
				
			||||||
export * from './auth';
 | 
					export * from './auth';
 | 
				
			||||||
 | 
					export * from './tab';
 | 
				
			||||||
export * from './route';
 | 
					export * from './route';
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,10 +3,13 @@ import { defineStore } from 'pinia';
 | 
				
			|||||||
import { fetchUserRoutes } from '@/service';
 | 
					import { fetchUserRoutes } from '@/service';
 | 
				
			||||||
import { transformAuthRouteToMenu, transformAuthRoutesToVueRoutes } from '@/utils';
 | 
					import { transformAuthRouteToMenu, transformAuthRoutesToVueRoutes } from '@/utils';
 | 
				
			||||||
import type { GlobalMenuOption } from '@/interface';
 | 
					import type { GlobalMenuOption } from '@/interface';
 | 
				
			||||||
 | 
					import { useTabStore } from '../tab';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface RouteState {
 | 
					interface RouteState {
 | 
				
			||||||
  /** 是否添加过动态路由 */
 | 
					  /** 是否添加过动态路由 */
 | 
				
			||||||
  isAddedDynamicRoute: boolean;
 | 
					  isAddedDynamicRoute: boolean;
 | 
				
			||||||
 | 
					  /** 路由首页name */
 | 
				
			||||||
 | 
					  routeHomeName: AuthRoute.RouteKey;
 | 
				
			||||||
  /** 菜单 */
 | 
					  /** 菜单 */
 | 
				
			||||||
  menus: GlobalMenuOption[];
 | 
					  menus: GlobalMenuOption[];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -14,6 +17,7 @@ interface RouteState {
 | 
				
			|||||||
export const useRouteStore = defineStore('route-store', {
 | 
					export const useRouteStore = defineStore('route-store', {
 | 
				
			||||||
  state: (): RouteState => ({
 | 
					  state: (): RouteState => ({
 | 
				
			||||||
    isAddedDynamicRoute: false,
 | 
					    isAddedDynamicRoute: false,
 | 
				
			||||||
 | 
					    routeHomeName: 'dashboard_analysis',
 | 
				
			||||||
    menus: []
 | 
					    menus: []
 | 
				
			||||||
  }),
 | 
					  }),
 | 
				
			||||||
  actions: {
 | 
					  actions: {
 | 
				
			||||||
@@ -22,8 +26,11 @@ export const useRouteStore = defineStore('route-store', {
 | 
				
			|||||||
     * @param router - 路由实例
 | 
					     * @param router - 路由实例
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    async initDynamicRoute(router: Router) {
 | 
					    async initDynamicRoute(router: Router) {
 | 
				
			||||||
 | 
					      const { initHomeTab } = useTabStore();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const { data } = await fetchUserRoutes();
 | 
					      const { data } = await fetchUserRoutes();
 | 
				
			||||||
      if (data) {
 | 
					      if (data) {
 | 
				
			||||||
 | 
					        this.routeHomeName = data.home;
 | 
				
			||||||
        this.menus = transformAuthRouteToMenu(data.routes);
 | 
					        this.menus = transformAuthRouteToMenu(data.routes);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const vueRoutes = transformAuthRoutesToVueRoutes(data.routes);
 | 
					        const vueRoutes = transformAuthRoutesToVueRoutes(data.routes);
 | 
				
			||||||
@@ -31,6 +38,7 @@ export const useRouteStore = defineStore('route-store', {
 | 
				
			|||||||
          router.addRoute(route);
 | 
					          router.addRoute(route);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        initHomeTab(data.home, router);
 | 
				
			||||||
        this.isAddedDynamicRoute = true;
 | 
					        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[];
 | 
					    routes: AuthRoute.Route[];
 | 
				
			||||||
    /** 路由首页对应的key */
 | 
					    /** 路由首页对应的key */
 | 
				
			||||||
    home: AuthRoute.RoutePath;
 | 
					    home: AuthRoute.RouteKey;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
export * from './helpers';
 | 
					export * from './helpers';
 | 
				
			||||||
export * from './menu';
 | 
					export * from './menu';
 | 
				
			||||||
export * from './breadcrumb';
 | 
					export * from './breadcrumb';
 | 
				
			||||||
 | 
					export * from './tab';
 | 
				
			||||||
export * from './regexp';
 | 
					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();
 | 
					  renderPieChart();
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
<style scoped></style>
 | 
					<style></style>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,7 @@ export default defineConfig(configEnv => {
 | 
				
			|||||||
    css: {
 | 
					    css: {
 | 
				
			||||||
      preprocessorOptions: {
 | 
					      preprocessorOptions: {
 | 
				
			||||||
        scss: {
 | 
					        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