feat(projects): 创建自定义布局组件SoybeanLayout

This commit is contained in:
Soybean 2022-01-07 13:22:13 +08:00
parent 006467a062
commit 0653fb144f
18 changed files with 606 additions and 152 deletions

View File

@ -121,6 +121,11 @@ module.exports = {
group: 'internal',
position: 'before'
},
{
pattern: '@/**',
group: 'internal',
position: 'before'
},
{
pattern: '@/interface',
group: 'internal',

View File

@ -1,4 +1,5 @@
export * from './custom';
export * from './svg';
export * from './custom';
export * from './common';
export * from './business';

View File

@ -1,92 +0,0 @@
<template>
<div class="flex flex-col h-full">
<header
:class="{ 'fixed-lt': topFixed }"
:style="{ height: headerHeight + 'px', paddingLeft: headerPaddingLeft }"
class="z-1001 flex-shrink-0 w-full bg-white border-b border-gray-200 transition-all duration-300 ease-in-out"
>
<slot name="header">
<h3>Header</h3>
</slot>
</header>
<div
:class="{ fixed: topFixed }"
:style="{ top: headerHeight + 'px', height: tabHeight + 'px', paddingLeft: siderWidth }"
class="left-0 z-999 flex-shrink-0 w-full bg-white border-b border-gray-200 transition-all duration-300 ease-in-out"
>
<slot name="tab">
<div>Tab</div>
</slot>
</div>
<aside
:style="{ width: siderWidth, paddingTop: siderPaddingTop }"
:class="[isVertical ? 'z-1002' : 'z-1000']"
class="fixed-lt h-full transition-all duration-300 ease-in-out bg-white border-r border-gray-200"
>
<slot name="sider">
<n-space :vertical="true" align="center" class="pt-24px">
<n-button type="primary" @click="toggle">折叠</n-button>
<div>
<span class="pr-12px">固定头部和标签</span>
<n-switch v-model:value="fixed" />
</div>
<div>
<span class="pr-12px">vertical布局</span>
<n-switch v-model:value="isVertical" />
</div>
</n-space>
</slot>
</aside>
<main
class="flex-1 transition-all duration-300 ease-in-out"
:style="{ paddingLeft: siderWidth, paddingTop: mainPaddingTop }"
>
<slot></slot>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { NSpace, NButton, NSwitch } from 'naive-ui';
import { useBoolean } from '@/hooks';
interface Props {
/** 头部高度 */
headerHeight?: number;
/** 标签页高度 */
tabHeight?: number;
/** 固定头部和标签 */
fixdTop?: boolean;
/** 侧边栏高度 */
siderWidth?: number;
/** 侧边栏折叠状态的高度 */
siderCollapsedWidth?: number;
/** 侧边栏折叠状态 */
siderCollapse?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
headerHeight: 56,
tabHeight: 44,
fixdTop: true,
topZIndex: 1000,
siderWidth: 200,
siderZIndex: 1001,
siderCollapsedWidth: 64,
siderCollapse: false
});
const { bool: collapse, toggle } = useBoolean();
const fixed = ref(true);
const isVertical = ref(true);
const topFixed = computed(() => fixed.value || !isVertical.value);
const siderWidth = computed(() => `${collapse.value ? props.siderCollapsedWidth : props.siderWidth}px`);
const headerPaddingLeft = computed(() => (isVertical.value ? siderWidth.value : '0px'));
const siderPaddingTop = computed(() => (isVertical.value ? '0px' : `${props.headerHeight}px`));
const mainPaddingTop = computed(() => `${fixed.value ? props.headerHeight + props.tabHeight : 0}px`);
</script>
<style scoped></style>

View File

@ -1,3 +0,0 @@
import BasicLayout from './BasicLayout.vue';
export { BasicLayout };

View File

@ -1,11 +1,80 @@
<template>
<basic-layout>
<soybean-layout :mode="mode" :fixed-header-and-tab="fixed" :fixed-footer="fixedFooter" :sider-collapse="collapse">
<template #header>
<div class="flex justify-end h-full bg-red-600">
<h3 class="text-white">Header</h3>
</div>
</template>
<template #tab>
<div class="h-full bg-green-600"></div>
</template>
<template #sider>
<div class="w-full h-full bg-gray-200">
<n-space :vertical="true" align="center" class="pt-24px">
<n-button type="primary" @click="toggle">折叠</n-button>
<div>
<span class="pr-12px">固定头部和标签</span>
<n-switch v-model:value="fixed" />
</div>
<div>
<span class="pr-12px">固定底部</span>
<n-switch v-model:value="fixedFooter" />
</div>
<div>
<span class="pr-12px">vertical布局</span>
<n-radio-group v-model:value="mode">
<n-radio v-for="item in radios" :key="item.value" :value="item.value">
{{ item.label }}
</n-radio>
</n-radio-group>
</div>
</n-space>
</div>
</template>
<global-content />
</basic-layout>
<template #footer>
<div class="h-full bg-blue-400">
<h3>footer</h3>
</div>
</template>
</soybean-layout>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { NSpace, NButton, NSwitch, NRadioGroup, NRadio } from 'naive-ui';
import { useElementSize } from '@vueuse/core';
import { useBoolean } from '@/hooks';
import { SoybeanLayout } from '@/package';
import { GlobalContent } from '../common';
import { BasicLayout } from './components';
type LayoutMode = 'vertical' | 'horizontal';
interface ModeRadio {
value: LayoutMode;
label: string;
}
const { width } = useElementSize(document.documentElement);
const { bool: collapse, toggle } = useBoolean();
const minWidthOfLayout = 1200;
const fixed = ref(true);
const fixedFooter = ref(true);
const mode = ref<LayoutMode>('vertical');
const radios: ModeRadio[] = [
{ value: 'vertical', label: 'vertical' },
{ value: 'horizontal', label: 'horizontal' }
];
watch(width, newValue => {
if (newValue < minWidthOfLayout) {
document.documentElement.style.overflowX = 'auto';
} else {
document.documentElement.style.overflowX = 'hidden';
}
});
</script>
<style scoped></style>

View File

@ -0,0 +1,41 @@
<template>
<main class="soybean-layout__main" :style="style">
<slot></slot>
</main>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 顶部内边距 */
paddingTop?: number;
/** 底部内边距 */
paddingBottom?: number;
/** 左侧内边距 */
paddingLeft?: number;
/** 动画过渡时间 */
transitionDuration?: number;
/** 动画过渡时间 */
transitionTimingFunction?: string;
}
const props = withDefaults(defineProps<Props>(), {
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
transitionDuration: 300,
transitionTimingFunction: 'ease-in-out'
});
const style = computed(() => {
const { paddingTop, paddingBottom, paddingLeft, transitionDuration, transitionTimingFunction } = props;
return `padding-top: ${paddingTop}px;padding-bottom: ${paddingBottom}px;padding-left: ${paddingLeft}px;transition-duration: ${transitionDuration}ms;transition-timing-function: ${transitionTimingFunction};`;
});
</script>
<style scoped>
.soybean-layout__main {
flex-grow: 1;
transition-property: padding-left;
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<header class="soybean-layout__footer" :style="style">
<slot></slot>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 开启fixed布局 */
fixed?: boolean;
/** fixed布局的层级 */
zIndex?: number;
/** 最小宽度 */
minWidth?: number;
/** 高度 */
height?: number;
/** 左侧内边距 */
paddingLeft?: number;
/** 动画过渡时间 */
transitionDuration?: number;
/** 动画过渡时间 */
transitionTimingFunction?: string;
}
const props = withDefaults(defineProps<Props>(), {
fixed: true,
zIndex: 999,
minWidth: 1200,
height: 56,
paddingLeft: 0,
transitionDuration: 300,
transitionTimingFunction: 'ease-in-out'
});
const style = computed(() => {
const { fixed, zIndex, minWidth, height, paddingLeft, transitionDuration, transitionTimingFunction } = props;
const position = fixed ? 'fixed' : 'static';
return `position: ${position};z-index: ${zIndex};min-width: ${minWidth}px;height: ${height}px;padding-left: ${paddingLeft}px;transition-duration: ${transitionDuration}ms;transition-timing-function: ${transitionTimingFunction};`;
});
</script>
<style scoped>
.soybean-layout__footer {
left: 0;
bottom: 0;
flex-shrink: 0;
width: 100%;
transition-property: padding-left;
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<header class="soybean-layout__header" :style="style">
<slot></slot>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 开启fixed布局 */
fixed?: boolean;
/** fixed布局的层级 */
zIndex?: number;
/** 最小宽度 */
minWidth?: number;
/** 高度 */
height?: number;
/** 左侧内边距 */
paddingLeft?: number;
/** 动画过渡时间 */
transitionDuration?: number;
/** 动画过渡时间 */
transitionTimingFunction?: string;
}
const props = withDefaults(defineProps<Props>(), {
fixed: true,
zIndex: 1001,
minWidth: 1200,
height: 56,
paddingLeft: 0,
transitionDuration: 300,
transitionTimingFunction: 'ease-in-out'
});
const style = computed(() => {
const { fixed, zIndex, minWidth, height, paddingLeft, transitionDuration, transitionTimingFunction } = props;
const position = fixed ? 'fixed' : 'static';
return `position: ${position};z-index: ${zIndex};min-width: ${minWidth}px;height: ${height}px;padding-left: ${paddingLeft}px;transition-duration: ${transitionDuration}ms;transition-timing-function: ${transitionTimingFunction};`;
});
</script>
<style scoped>
.soybean-layout__header {
left: 0;
top: 0;
flex-shrink: 0;
width: 100%;
transition-property: padding-left;
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<aside class="soybean-layout__sider" :style="style">
<slot></slot>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** fixed布局的层级 */
zIndex?: number;
/** 宽度 */
width?: number;
/** 顶部内边距 */
paddingTop?: number;
/** 动画过渡时间 */
transitionDuration?: number;
/** 动画过渡时间 */
transitionTimingFunction?: string;
}
const props = withDefaults(defineProps<Props>(), {
zIndex: 1002,
width: 200,
paddingTop: 0,
transitionDuration: 300,
transitionTimingFunction: 'ease-in-out'
});
const style = computed(() => {
const { zIndex, width, paddingTop, transitionDuration, transitionTimingFunction } = props;
return `z-index: ${zIndex};width: ${width}px;padding-top: ${paddingTop}px;transition-duration: ${transitionDuration}ms;transition-timing-function: ${transitionTimingFunction};`;
});
</script>
<style scoped>
.soybean-layout__sider {
position: fixed;
left: 0;
top: 0;
height: 100%;
transition-property: all;
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<div class="soybean-layout__tab" :style="style">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 开启fixed布局 */
fixed?: boolean;
/** fixed布局的top距离 */
top?: number;
/** fixed布局的层级 */
zIndex?: number;
/** 最小宽度 */
minWidth?: number;
/** 高度 */
height?: number;
/** 左侧内边距 */
paddingLeft?: number;
/** 动画过渡时间 */
transitionDuration?: number;
/** 动画过渡时间 */
transitionTimingFunction?: string;
}
const props = withDefaults(defineProps<Props>(), {
fixed: true,
top: 56,
zIndex: 999,
minWidth: 1200,
height: 56,
paddingLeft: 0,
transitionDuration: 300,
transitionTimingFunction: 'ease-in-out'
});
const style = computed(() => {
const { fixed, top, zIndex, minWidth, height, paddingLeft, transitionDuration, transitionTimingFunction } = props;
const position = fixed ? 'fixed' : 'static';
return `position: ${position};top: ${top}px;z-index: ${zIndex};min-width: ${minWidth}px;height: ${height}px;padding-left: ${paddingLeft}px;transition-duration: ${transitionDuration}ms;transition-timing-function: ${transitionTimingFunction};`;
});
</script>
<style scoped>
.soybean-layout__tab {
left: 0;
flex-shrink: 0;
width: 100%;
transition-property: padding-left;
}
</style>

View File

@ -0,0 +1,7 @@
import LayoutHeader from './LayoutHeader.vue';
import LayoutTab from './LayoutTab.vue';
import LayoutSider from './LayoutSider.vue';
import LayoutContent from './LayoutContent.vue';
import LayoutFooter from './LayoutFooter.vue';
export { LayoutSider, LayoutHeader, LayoutTab, LayoutContent, LayoutFooter };

View File

@ -0,0 +1,53 @@
import { ref, computed, watch, onUnmounted } from 'vue';
import type { Ref, ComputedRef } from 'vue';
/**
* 使translateX
* @param isFixed - fixed布局
*/
export function useFixedTransformStyle(isFixed: Ref<boolean> | ComputedRef<boolean>) {
const scrollLeft = ref(0);
const transformStyle = computed(() => `transform: translateX(${-scrollLeft.value}px);`);
function setScrollLeft(sLeft: number) {
scrollLeft.value = sLeft;
}
function scrollHandler() {
const sLeft = document.scrollingElement?.scrollLeft || 0;
setScrollLeft(sLeft);
}
function initScrollLeft() {
scrollHandler();
}
function addScrollEventListener() {
document.addEventListener('scroll', scrollHandler);
}
function removeScrollEventListener() {
document.removeEventListener('scroll', scrollHandler);
}
function init() {
initScrollLeft();
addScrollEventListener();
}
watch(
isFixed,
newValue => {
if (newValue) {
init();
} else {
removeScrollEventListener();
}
},
{ immediate: true }
);
onUnmounted(() => {
removeScrollEventListener();
});
return transformStyle;
}

View File

@ -0,0 +1,173 @@
<template>
<div class="soybean-layout" :style="{ minWidth: minWidth + 'px' }">
<layout-header
v-if="headerVisible"
v-bind="commonProps"
:fixed="fixedHeaderAndTab"
:z-index="headerZIndex"
:min-width="minWidth"
:height="headerHeight"
:padding-left="headerPaddingLeft"
:style="headerAndTabTransform"
>
<slot name="header"></slot>
</layout-header>
<layout-tab
v-if="tabVisible"
v-bind="commonProps"
:fixed="fixedHeaderAndTab"
:z-index="tabZIndex"
:top="headerHeight"
:height="tabHeight"
:padding-left="tabPaddingLeft"
:style="headerAndTabTransform"
>
<slot name="tab"></slot>
</layout-tab>
<layout-sider
v-if="siderVisible"
v-bind="commonProps"
:z-index="siderZIndex"
:width="siderWidth"
:padding-top="siderPaddingTop"
>
<slot name="sider"></slot>
</layout-sider>
<layout-content
v-bind="commonProps"
:padding-top="contentPaddingTop"
:padding-bottom="contentPaddingBottom"
:padding-left="siderWidth"
>
<slot></slot>
</layout-content>
<layout-footer
v-if="footerVisible"
v-bind="commonProps"
:fixed="fixedFooter"
:z-index="footerZIndex"
:height="footerHeight"
:padding-left="siderWidth"
:style="footerTransform"
>
<slot name="footer"></slot>
</layout-footer>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { LayoutHeader, LayoutTab, LayoutSider, LayoutContent, LayoutFooter } from './components';
import { useFixedTransformStyle } from './hooks';
interface Props {
/** 布局模式 */
mode?: 'vertical' | 'horizontal';
/** 最小宽度 */
minWidth?: number;
/** 头部可见 */
headerVisible?: boolean;
/** 头部高度 */
headerHeight?: number;
/** 标签可见 */
tabVisible?: boolean;
/** 标签页高度 */
tabHeight?: number;
/** 固定头部和标签 */
fixedHeaderAndTab?: boolean;
/** 底部可见 */
footerVisible?: boolean;
/** 底部高度 */
footerHeight?: number;
/** 固定底部 */
fixedFooter?: boolean;
/** 侧边可见 */
siderVisible?: boolean;
/** 侧边栏高度 */
siderWidth?: number;
/** 侧边栏折叠状态的高度 */
siderCollapsedWidth?: number;
/** 侧边栏折叠状态 */
siderCollapse?: boolean;
/** 动画过渡时间 */
transitionDuration?: number;
/** 动画过渡时间 */
transitionTimingFunction?: string;
}
const props = withDefaults(defineProps<Props>(), {
mode: 'vertical',
minWidth: 1200,
headerVisible: true,
headerHeight: 56,
tabVisible: true,
tabHeight: 44,
fixedHeaderAndTab: true,
footerVisible: true,
footerHeight: 48,
fixedFooter: true,
siderVisible: true,
siderWidth: 200,
siderCollapsedWidth: 64,
siderCollapse: false,
transitionDuration: 300,
transitionTimingFunction: 'ease-in-out'
});
// fixedtranslateX(fixed)
const hasFixedEl = computed(() => props.fixedHeaderAndTab || props.fixedFooter);
const transformStyle = useFixedTransformStyle(hasFixedEl);
const headerAndTabTransform = computed(() => (props.fixedHeaderAndTab ? transformStyle.value : ''));
const footerTransform = computed(() => (props.fixedFooter ? transformStyle.value : ''));
/** 各个子组件的公共属性 */
const commonProps = computed(() => {
const { transitionDuration, transitionTimingFunction } = props;
return {
transitionDuration,
transitionTimingFunction
};
});
/** 水平布局 */
const isVertical = computed(() => props.mode === 'vertical');
// fixed
const headerZIndex = 1001;
const tabZIndex = 999;
const siderZIndex = computed(() => (isVertical.value ? 1002 : 1000));
const footerZIndex = 999;
/** 侧边宽度 */
const siderWidth = computed(() => {
const { siderCollapse, siderWidth, siderCollapsedWidth } = props;
const width = siderCollapse ? siderCollapsedWidth : siderWidth;
return props.siderVisible ? width : 0;
});
//
const headerPaddingLeft = computed(() => (isVertical.value ? siderWidth.value : 0));
const tabPaddingLeft = computed(() => (isVertical.value ? siderWidth.value : 0));
const siderPaddingTop = computed(() => (!isVertical.value && props.headerVisible ? props.headerHeight : 0));
const contentPaddingTop = computed(() => {
let height = 0;
if (props.fixedHeaderAndTab) {
if (props.headerVisible) {
height += props.headerHeight;
}
if (props.tabVisible) {
height += props.tabHeight;
}
}
return height;
});
const contentPaddingBottom = computed(() => (props.fixedFooter && props.footerVisible ? props.footerHeight : 0));
</script>
<style scoped>
.soybean-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

3
src/package/index.ts Normal file
View File

@ -0,0 +1,3 @@
import SoybeanLayout from './SoybeanLayout/index.vue';
export { SoybeanLayout };

View File

@ -1,22 +1,20 @@
import type { GlobalThemeOverrides } from 'naive-ui';
import { kebabCase } from 'lodash-es';
import { getColorPalette, addColorAlpha } from '@/utils';
type ColorType = 'primary' | 'info' | 'success' | 'warning' | 'error';
type ColorScene = '' | 'Suppl' | 'Hover' | 'Pressed' | 'Active';
type ColorKey = `${ColorType}Color${ColorScene}`;
type ThemeColor = {
[key in ColorKey]?: string;
};
interface ColorAction {
scene: ColorScene;
handler: (color: string) => string;
}
/** 获取主题颜色的各种场景对应的颜色 */
export function getThemeColors(colors: [ColorType, string][]) {
function getThemeColors(colors: [ColorType, string][]) {
const colorActions: ColorAction[] = [
{ scene: '', handler: color => color },
{ scene: 'Suppl', handler: color => color },
@ -38,23 +36,51 @@ export function getThemeColors(colors: [ColorType, string][]) {
return themeColor;
}
/** 获取naive的主题颜色 */
export function getNaiveThemeOverrides(colors: { [key in ColorType]: string }): GlobalThemeOverrides {
const { primary, info, success, warning, error } = colors;
const themeColors = getThemeColors([
['primary', primary],
['info', info],
['success', success],
['warning', warning],
['error', error]
]);
const colorLoading = primary;
return {
common: {
...themeColors
},
LoadingBar: {
colorLoading
}
};
}
type ThemeVars = Exclude<GlobalThemeOverrides['common'], undefined>;
type ThemeVarsKeys = keyof ThemeVars;
/** 添加css vars至html */
export function addThemeCssVarsToHtml(themeVars: ThemeVars) {
const keys = Object.keys(themeVars) as ThemeVarsKeys[];
const style: string[] = [];
keys.forEach(key => {
style.push(`--${kebabCase(key)}: ${themeVars[key]}`);
});
const styleStr = style.join(';');
document.documentElement.style.cssText = styleStr;
}
/** windicss 暗黑模式 */
export function handleWindicssDarkMode() {
const DARK_CLASS = 'dark';
function getHtmlElement() {
return document.querySelector('html');
}
function addDarkClass() {
const html = getHtmlElement();
if (html) {
html.classList.add(DARK_CLASS);
}
document.documentElement.classList.add(DARK_CLASS);
}
function removeDarkClass() {
const html = getHtmlElement();
if (html) {
html.classList.remove(DARK_CLASS);
}
document.documentElement.classList.remove(DARK_CLASS);
}
return {
addDarkClass,

View File

@ -3,10 +3,9 @@ import type { Ref, ComputedRef } from 'vue';
import { defineStore } from 'pinia';
import { useThemeVars, darkTheme, useOsTheme } from 'naive-ui';
import type { GlobalThemeOverrides, GlobalTheme } from 'naive-ui';
import { kebabCase } from 'lodash-es';
import { useBoolean } from '@/hooks';
import { getColorPalette } from '@/utils';
import { getThemeColors, handleWindicssDarkMode } from './helpers';
import { getNaiveThemeOverrides, addThemeCssVarsToHtml, handleWindicssDarkMode } from './helpers';
interface OtherColor {
/** 信息 */
@ -38,8 +37,6 @@ interface ThemeStore {
naiveTheme: ComputedRef<BuiltInGlobalTheme | undefined>;
}
type ThemeVarsKeys = keyof Exclude<GlobalThemeOverrides['common'], undefined>;
export const useThemeStore = defineStore('theme-store', () => {
const themeVars = useThemeVars();
const { bool: darkMode, setBool: setDarkMode, toggle: toggleDarkMode } = useBoolean();
@ -53,27 +50,9 @@ export const useThemeStore = defineStore('theme-store', () => {
error: '#f5222d'
}));
const naiveThemeOverrides = computed<GlobalThemeOverrides>(() => {
const { info, success, warning, error } = otherColor.value;
const themeColors = getThemeColors([
['primary', themeColor.value],
['info', info],
['success', success],
['warning', warning],
['error', error]
]);
const colorLoading = themeColor.value;
return {
common: {
...themeColors
},
LoadingBar: {
colorLoading
}
};
});
const naiveThemeOverrides = computed<GlobalThemeOverrides>(() =>
getNaiveThemeOverrides({ primary: themeColor.value, ...otherColor.value })
);
/** naive-ui暗黑主题 */
const naiveTheme = computed(() => (darkMode.value ? darkTheme : undefined));
@ -81,22 +60,14 @@ export const useThemeStore = defineStore('theme-store', () => {
/** 操作系统暗黑主题 */
const osTheme = useOsTheme();
/** 添加css vars至html */
function addThemeCssVarsToHtml() {
if (document.documentElement.style.cssText) return;
/** 初始化css vars, 并添加至html */
function initThemeCssVars() {
const updatedThemeVars = { ...themeVars.value, ...naiveThemeOverrides.value.common };
const keys = Object.keys(updatedThemeVars) as ThemeVarsKeys[];
const style: string[] = [];
keys.forEach(key => {
style.push(`--${kebabCase(key)}: ${updatedThemeVars[key]}`);
});
const styleStr = style.join(';');
document.documentElement.style.cssText = styleStr;
addThemeCssVarsToHtml(updatedThemeVars);
}
function init() {
addThemeCssVarsToHtml();
initThemeCssVars();
}
init();

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="h-full">
<h3>DashboardAnalysis</h3>
<router-link to="/about">about</router-link>
</div>

View File

@ -40,6 +40,7 @@ export default defineConfig({
'fixed-center': 'fixed left-0 top-0 flex-center wh-full',
'nowrap-hidden': 'whitespace-nowrap overflow-hidden',
'ellipsis-text': 'nowrap-hidden overflow-ellipsis',
'transition-base': 'transition-all duration-300 ease-in-out',
'init-loading-spin': 'w-16px h-16px bg-primary rounded-8px animate-pulse'
},
theme: {