feat(projects): 迁移多页签
This commit is contained in:
parent
cc290accc2
commit
28efbdbc70
@ -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 *;`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user