fix(projects): 修复vertical-mix布局、重构初始化的loading

This commit is contained in:
Soybean 2022-01-18 01:17:09 +08:00
parent b2a4ddf5e3
commit 579e07400e
32 changed files with 563 additions and 737 deletions

View File

@ -7,20 +7,23 @@
<title><%= appName %></title> <title><%= appName %></title>
</head> </head>
<body> <body>
<div id="appProvider" style="display: none"></div>
<div id="app"> <div id="app">
<div class="fixed-center flex-col"> <div class="loading-container">
<div id="loadingLogo" class="w-128px h-128px text-primary"></div> <div id="loadingLogo" class="loading-svg"></div>
<div class="w-56px h-56px my-36px"> <div class="loading-spin__container">
<div class="relative h-full animate-spin"> <div class="loading-spin">
<div class="absolute-lt init-loading-spin"></div> <div class="left-0 top-0 loading-spin-item"></div>
<div class="absolute-lb init-loading-spin animate-delay-500"></div> <div class="left-0 bottom-0 loading-spin-item loading-delay-500"></div>
<div class="absolute-rt init-loading-spin animate-delay-1000"></div> <div class="right-0 top-0 loading-spin-item loading-delay-1000"></div>
<div class="absolute-rb init-loading-spin animate-delay-1500"></div> <div class="right-0 bottom-0 loading-spin-item loading-delay-1500"></div>
</div> </div>
</div> </div>
<h2 class="text-28px font-medium text-[#646464]"><%= appTitle %></h2> <h2 class="loading-title"><%= appTitle %></h2>
</div> </div>
<style>
@import '/resource/loading.css';
</style>
<script src="/resource/loading.js"></script>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View File

@ -0,0 +1,91 @@
.loading-container {
position: fixed;
left: 0;
top: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.loading-svg {
width: 128px;
height: 128px;
color: var(--primary-color);
}
.loading-spin__container {
width: 56px;
height: 56px;
margin: 36px 0;
}
.loading-spin {
position: relative;
height: 100%;
animation: loadingSpin 1s linear infinite;
}
.left-0 {
left: 0;
}
.right-0 {
right: 0;
}
.top-0 {
top: 0;
}
.bottom-0 {
bottom: 0;
}
.loading-spin-item {
position: absolute;
height: 16px;
width: 16px;
background-color: var(--primary-color);
border-radius: 8px;
-webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes loadingSpin {
from {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loadingPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.loading-delay-500 {
-webkit-animation-delay: 500ms;
animation-delay: 500ms;
}
.loading-delay-1000 {
-webkit-animation-delay: 1000ms;
animation-delay: 1000ms;
}
.loading-delay-1500 {
-webkit-animation-delay: 1500ms;
animation-delay: 1500ms;
}
.loading-title {
font-size: 28px;
font-weight: 500;
color: #646464;
}

View File

@ -0,0 +1,44 @@
/**
* 初始化加载效果的svg格式logo
* @param { string }id - 元素id
*/
function initSvgLogo(id) {
const svgStr = `<svg width="128px" height="128px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 158.9 158.9" style="enable-background:new 0 0 158.9 158.9;" xml:space="preserve">
<path style="fill:none" d="M0,158.9C0,106.3,0,53.7,0,1.1C0,0.2,0.2,0,1.1,0c52.2,0,104.5,0,156.7,0c0.9,0,1.1,0.2,1.1,1.1
c0,52.2,0,104.5,0,156.7c0,0.9-0.2,1.1-1.1,1.1C105.2,158.8,52.6,158.8,0,158.9z" />
<path style="fill:currentColor" d="M81.3,55.9c-0.1-11.7-2.9-22.5-9.4-32.4c-1-1.5-2.1-2.9-2.5-4.7c-0.7-3.4,0.9-6.9,4-8.6c3-1.7,6.8-1.2,9.3,1.2
c2.4,2.6,4.4,5.6,5.9,8.8c4.7,8.9,7.6,18.6,8.4,28.6c1,12.5-0.7,25-5.2,36.7c-0.9,2.5-1.9,4.9-3,7.3c-0.3,0.4-0.3,1,0,1.4
c9.6,13.3,21.8,23,37.8,27.2c6.4,1.7,13.1,2.3,19.7,1.6c4.2-0.4,7.9,2.7,8.4,6.9c0.7,4.3-2.3,8.3-6.6,9c0,0,0,0-0.1,0
c-7.7,0.9-15.5,0.5-23-1.3c-13.9-3.1-26.7-10-36.9-19.9c-4.4-4.2-8.4-8.8-11.9-13.7c-0.5-0.8-1.4-1.2-2.3-1.1
c-9.5,0.7-18.8,3.3-27.4,7.6c-11.6,6-20.7,14.6-26.4,26.4c-0.7,1.9-2,3.5-3.7,4.7c-2.9,1.7-6.6,1.5-9.2-0.7c-2.8-2.2-3.8-6-2.4-9.3
c2.2-5.2,5.1-10.1,8.7-14.5c12.2-15.4,28.2-24.6,47.3-28.6c4-0.8,8.1-1.4,12.2-1.6c0.5,0,1-0.3,1.2-0.8c3.3-7.1,5.5-14.6,6.5-22.3
C81.1,61.2,81.3,58.6,81.3,55.9z" />
<path style="fill:currentColor" d="M136.3,108.3c-3.8-0.5-7.6-1.4-11.1-2.9c-7.7-2.8-14.4-7.5-19.7-13.8c-2.9-3.3-2.5-8.4,0.8-11.3
c1.4-1.2,3.1-1.9,4.9-1.9c2.5-0.1,5,1,6.5,2.9c4.9,5.6,11.6,9.4,18.9,10.8c1.5,0.2,3.1,0.6,4.5,1.2c3.2,1.8,4.8,5.6,3.8,9.2
C144,106.1,140.8,108.4,136.3,108.3z" />
<path style="fill:currentColor" d="M55.7,33.3c3,0.2,5.6,2.2,6.6,5c2.2,5.4,3.4,11.2,3.6,17c0.3,5.9-0.6,11.7-2.5,17.3c-2,5.8-8.2,7.8-12.9,4.2
c-2.6-2.2-3.6-5.8-2.4-9c1.4-4,1.9-8.2,1.7-12.4c-0.2-3.8-1-7.5-2.4-11C45.3,38.9,49.2,33.3,55.7,33.3z" />
<path style="fill:currentColor" d="M77.9,126.6c0,3.9-2.8,7.2-6.7,7.9c-7.8,1.5-14.8,5.9-19.7,12.2c-2.7,3.5-7.6,4.2-11.2,1.6
c-3.6-2.6-4.3-7.6-1.7-11.2c0.1-0.1,0.2-0.3,0.3-0.4c4.1-5.2,9.3-9.6,15.1-12.8c4.4-2.5,9.1-4.2,14-5.1
C73.3,117.7,77.9,121.3,77.9,126.6z" />
</svg>
`;
const appEl = document.querySelector(id);
const div = document.createElement('div');
div.innerHTML = svgStr;
if (appEl) {
appEl.appendChild(div);
}
}
function addThemeColorCssVars() {
const key = '__THEME_COLOR__';
const themeColor = '#1890ff';
const cssVars = window.localStorage.getItem(key) || `--primary-color: ${themeColor}`;
document.documentElement.style.cssText = cssVars;
}
initSvgLogo('#loadingLogo');
addThemeColorCssVars();

View File

@ -1,19 +1,24 @@
<template> <template>
<app-provider> <n-config-provider
<router-view /> :theme="theme.naiveTheme"
</app-provider> :theme-overrides="theme.naiveThemeOverrides"
:locale="zhCN"
:date-locale="dateZhCN"
class="h-full"
>
<naive-provider>
<router-view />
</naive-provider>
</n-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { subscribeStore } from '@/store'; import { NConfigProvider, zhCN, dateZhCN } from 'naive-ui';
import { useTheme } from '@/composables'; import { NaiveProvider } from '@/components';
import AppProvider from './AppProvider.vue'; import { useThemeStore, subscribeStore } from '@/store';
function init() { const theme = useThemeStore();
subscribeStore();
useTheme();
}
init(); subscribeStore();
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -1,22 +0,0 @@
<template>
<n-config-provider
:theme="theme.naiveTheme"
:theme-overrides="theme.naiveThemeOverrides"
:locale="zhCN"
:date-locale="dateZhCN"
class="h-full"
>
<naive-provider>
<slot></slot>
</naive-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { NConfigProvider, zhCN, dateZhCN } from 'naive-ui';
import { NaiveProvider } from '@/components';
import { useThemeStore } from '@/store';
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,4 +1,3 @@
export * from './system'; export * from './system';
export * from './router'; export * from './router';
export * from './theme';
export * from './layout'; export * from './layout';

View File

@ -1,37 +0,0 @@
import { watch, onUnmounted } from 'vue';
import { useOsTheme } from 'naive-ui';
import { useElementSize } from '@vueuse/core';
import { useThemeStore } from '@/store';
export function useTheme() {
const osTheme = useOsTheme();
const theme = useThemeStore();
const { width } = useElementSize(document.documentElement);
/** 监听操作系统主题模式 */
const stopHandle = watch(
osTheme,
newValue => {
const isDark = newValue === 'dark';
theme.setDarkMode(isDark);
},
{ immediate: true }
);
/**
*
* @description ,,
*/
const anotherStopHandle = watch(width, newValue => {
if (newValue < theme.layout.minWidth) {
document.documentElement.style.overflowX = 'auto';
} else {
document.documentElement.style.overflowX = 'hidden';
}
});
onUnmounted(() => {
stopHandle();
anotherStopHandle();
});
}

View File

@ -1,4 +1,6 @@
export enum EnumStorageKey { export enum EnumStorageKey {
/** 主题颜色 */
'theme-color' = '__THEME_COLOR__',
/** 用户token */ /** 用户token */
'token' = '__TOKEN__', 'token' = '__TOKEN__',
/** 用户刷新token */ /** 用户刷新token */

3
src/interface/expose.ts Normal file
View File

@ -0,0 +1,3 @@
export interface ExposeLayoutMixMenu {
resetFirstDegreeMenus(): void;
}

View File

@ -1,4 +1,5 @@
export * from './enum'; export * from './enum';
export * from './theme'; export * from './theme';
export * from './system'; export * from './system';
export * from './expose';
export * from './layout'; export * from './layout';

View File

@ -4,7 +4,11 @@
<n-breadcrumb-item> <n-breadcrumb-item>
<n-dropdown v-if="breadcrumb.hasChildren" :options="breadcrumb.children" @select="dropdownSelect"> <n-dropdown v-if="breadcrumb.hasChildren" :options="breadcrumb.children" @select="dropdownSelect">
<span> <span>
<component :is="breadcrumb.icon" v-if="theme.header.crumb.showIcon" class="inline-block mr-4px text-16px" /> <component
:is="breadcrumb.icon"
v-if="theme.header.crumb.showIcon"
class="inline-block align-text-bottom mr-4px text-16px"
/>
<span>{{ breadcrumb.label }}</span> <span>{{ breadcrumb.label }}</span>
</span> </span>
</n-dropdown> </n-dropdown>

View File

@ -0,0 +1,14 @@
<template>
<n-button :text="true" class="h-36px" @click="app.toggleSiderCollapse">
<icon-ph-caret-double-right-bold v-if="app.siderCollapse" class="text-16px" />
<icon-ph-caret-double-left-bold v-else class="text-16px" />
</n-button>
</template>
<script lang="ts" setup>
import { NButton } from 'naive-ui';
import { useAppStore } from '@/store';
const app = useAppStore();
</script>
<style scoped></style>

View File

@ -0,0 +1,45 @@
<template>
<div class="mb-6px px-4px cursor-pointer" @mouseenter="setTrue" @mouseleave="setFalse">
<div
class="flex-center flex-col py-12px rounded-2px"
:class="{ 'text-primary bg-primary-active': isActive, 'text-primary': isHover }"
>
<component :is="icon" :class="[isMini ? 'text-16px' : 'text-20px']" />
<p
class="pt-8px text-12px overflow-hidden transition-height duration-300 ease-in-out"
:class="[isMini ? 'h-0 pt-0' : 'h-20px pt-8px']"
>
{{ label }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { VNodeChild } from 'vue';
import { useBoolean } from '@/hooks';
interface Props {
/** 路由名称 */
routeName: string;
/** 路由名称文本 */
label: string;
/** 当前激活状态的理由名称 */
activeRouteName: string;
/** 路由图标 */
icon?: () => VNodeChild;
/** mini尺寸的路由 */
isMini?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
icon: undefined,
isMini: false
});
const { bool: isHover, setTrue, setFalse } = useBoolean();
const isActive = computed(() => props.routeName === props.activeRouteName);
</script>
<style scoped></style>

View File

@ -0,0 +1,83 @@
<template>
<div
class="relative h-full transition-width duration-300 ease-in-out"
:style="{ width: app.mixSiderFixed ? theme.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<dark-mode-container
class="drawer-shadow absolute-lt flex-col-stretch h-full nowrap-hidden"
:style="{ width: showDrawer ? theme.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="header-height flex-y-center justify-between" :style="{ height: theme.header.height + 'px' }">
<h2 class="text-primary pl-8px text-16px font-bold">{{ title }}</h2>
<div class="px-8px text-16px text-gray-600 cursor-pointer" @click="app.toggleMixSiderFixed">
<icon-mdi-pin-off v-if="app.mixSiderFixed" />
<icon-mdi-pin v-else />
</div>
</header>
<n-scrollbar class="flex-1-hidden">
<n-menu
:value="activeKey"
:options="menus"
:expanded-keys="expandedKeys"
:indent="18"
@update:value="handleUpdateMenu"
@update:expanded-keys="handleUpdateExpandedKeys"
/>
</n-scrollbar>
</dark-mode-container>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import { NScrollbar, NMenu } from 'naive-ui';
import type { MenuOption } from 'naive-ui';
import { DarkModeContainer } from '@/components';
import { useAppStore, useThemeStore } from '@/store';
import { useAppInfo, useRouterPush } from '@/composables';
import { getActiveKeyPathsOfMenus } from '@/utils';
import type { GlobalMenuOption } from '@/interface';
interface Props {
/** 菜单抽屉可见性 */
visible: boolean;
/** 子菜单数据 */
menus: GlobalMenuOption[];
}
const props = defineProps<Props>();
const route = useRoute();
const app = useAppStore();
const theme = useThemeStore();
const { routerPush } = useRouterPush();
const showDrawer = computed(() => (props.visible && props.menus.length) || app.mixSiderFixed);
const { title } = useAppInfo();
const activeKey = computed(() => route.name as string);
const expandedKeys = ref<string[]>([]);
function handleUpdateMenu(_key: string, item: MenuOption) {
const menuItem = item as GlobalMenuOption;
routerPush(menuItem.routePath);
}
function handleUpdateExpandedKeys(keys: string[]) {
expandedKeys.value = keys;
}
watch(
() => route.name,
() => {
expandedKeys.value = getActiveKeyPathsOfMenus(activeKey.value, props.menus);
},
{ immediate: true }
);
</script>
<style scoped>
.drawer-shadow {
box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
}
</style>

View File

@ -0,0 +1,5 @@
import MixMenuDetail from './MixMenuDetail.vue';
import MixMenuDrawer from './MixMenuDrawer.vue';
import MixMenuCollapse from './MixMenuCollapse.vue';
export { MixMenuDetail, MixMenuDrawer, MixMenuCollapse };

View File

@ -0,0 +1,107 @@
<template>
<dark-mode-container class="flex h-full" @mouseleave="resetFirstDegreeMenus">
<div class="flex-1 flex-col-stretch h-full">
<global-logo :show-title="false" :style="{ height: theme.header.height + 'px' }" />
<n-scrollbar class="flex-1-hidden">
<mix-menu-detail
v-for="item in firstDegreeMenus"
:key="item.routeName"
:route-name="item.routeName"
:active-route-name="activeParentRouteName"
:label="item.label"
:icon="item.icon"
:is-mini="app.siderCollapse"
@click="handleMixMenu(item.routeName, item.hasChildren)"
/>
</n-scrollbar>
<mix-menu-collapse />
</div>
<mix-menu-drawer :visible="drawerVisible" :menus="activeChildMenus" />
</dark-mode-container>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { NScrollbar } from 'naive-ui';
import { DarkModeContainer } from '@/components';
import { useAppStore, useThemeStore, useRouteStore } from '@/store';
import { useRouterPush } from '@/composables';
import { useBoolean } from '@/hooks';
import { GlobalLogo } from '@/layouts/common';
import type { GlobalMenuOption } from '@/interface';
import { MixMenuDetail, MixMenuDrawer, MixMenuCollapse } from './components';
const route = useRoute();
const app = useAppStore();
const theme = useThemeStore();
const routeStore = useRouteStore();
const { routerPush } = useRouterPush();
const { bool: drawerVisible, setTrue: openDrawer, setFalse: hideDrawer } = useBoolean();
const activeParentRouteName = ref('');
function setActiveParentRouteName(routeName: string) {
activeParentRouteName.value = routeName;
}
const firstDegreeMenus = computed(() =>
routeStore.menus.map(item => {
const { routeName, label } = item;
const icon = item?.icon;
const hasChildren = Boolean(item.children && item.children.length);
return {
routeName,
label,
icon,
hasChildren
};
})
);
function getActiveParentRouteName() {
firstDegreeMenus.value.some(item => {
const routeName = route.name as string;
const flag = routeName?.includes(item.routeName);
if (flag) {
setActiveParentRouteName(item.routeName);
}
return flag;
});
}
function handleMixMenu(routeName: string, hasChildren: boolean) {
setActiveParentRouteName(routeName);
if (hasChildren) {
openDrawer();
} else {
routerPush({ name: routeName });
}
}
function resetFirstDegreeMenus() {
getActiveParentRouteName();
hideDrawer();
}
const activeChildMenus = computed(() => {
const menus: GlobalMenuOption[] = [];
routeStore.menus.some(item => {
const flag = item.routeName === activeParentRouteName.value && Boolean(item.children?.length);
if (flag) {
menus.push(...item.children!);
}
return flag;
});
return menus;
});
watch(
() => route.name,
() => {
getActiveParentRouteName();
},
{ immediate: true }
);
</script>
<style scoped></style>

View File

@ -1,11 +1,11 @@
<template> <template>
<n-scrollbar> <n-scrollbar class="flex-1-hidden">
<n-menu <n-menu
:value="activeKey" :value="activeKey"
:collapsed="app.siderCollapse" :collapsed="app.siderCollapse"
:collapsed-width="theme.sider.collapsedWidth" :collapsed-width="theme.sider.collapsedWidth"
:collapsed-icon-size="22" :collapsed-icon-size="22"
:options="menus" :options="routeStore.menus"
:expanded-keys="expandedKeys" :expanded-keys="expandedKeys"
:indent="18" :indent="18"
@update:value="handleUpdateMenu" @update:value="handleUpdateMenu"
@ -21,6 +21,7 @@ import { NScrollbar, NMenu } from 'naive-ui';
import type { MenuOption } from 'naive-ui'; import type { MenuOption } from 'naive-ui';
import { useAppStore, useThemeStore, useRouteStore } from '@/store'; import { useAppStore, useThemeStore, useRouteStore } from '@/store';
import { useRouterPush } from '@/composables'; import { useRouterPush } from '@/composables';
import { getActiveKeyPathsOfMenus } from '@/utils';
import type { GlobalMenuOption } from '@/interface'; import type { GlobalMenuOption } from '@/interface';
const route = useRoute(); const route = useRoute();
@ -29,26 +30,9 @@ const theme = useThemeStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const { routerPush } = useRouterPush(); const { routerPush } = useRouterPush();
const menus = computed(() => routeStore.menus as GlobalMenuOption[]);
const activeKey = computed(() => route.name as string); const activeKey = computed(() => route.name as string);
const expandedKeys = ref<string[]>([]); const expandedKeys = ref<string[]>([]);
function getExpendedKeys() {
const keys = menus.value.map(menu => getActiveKeysInMenus(menu)).flat();
return keys;
}
function getActiveKeysInMenus(menu: GlobalMenuOption) {
const keys: string[] = [];
if (activeKey.value.includes(menu.routeName)) {
keys.push(menu.routeName);
}
if (menu.children) {
keys.push(...menu.children.map(item => getActiveKeysInMenus(item as GlobalMenuOption)).flat(1));
}
return keys;
}
function handleUpdateMenu(_key: string, item: MenuOption) { function handleUpdateMenu(_key: string, item: MenuOption) {
const menuItem = item as GlobalMenuOption; const menuItem = item as GlobalMenuOption;
routerPush(menuItem.routePath); routerPush(menuItem.routePath);
@ -61,7 +45,7 @@ function handleUpdateExpandedKeys(keys: string[]) {
watch( watch(
() => route.name, () => route.name,
() => { () => {
expandedKeys.value = getExpendedKeys(); expandedKeys.value = getActiveKeyPathsOfMenus(activeKey.value, routeStore.menus);
}, },
{ immediate: true } { immediate: true }
); );

View File

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

View File

@ -0,0 +1,21 @@
<template>
<dark-mode-container class="flex-col-stretch h-full">
<global-logo v-if="!isHorizontalMix" :show-title="showTitle" :style="{ height: theme.header.height + 'px' }" />
<vertical-menu />
</dark-mode-container>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { DarkModeContainer } from '@/components';
import { useAppStore, useThemeStore } from '@/store';
import { GlobalLogo } from '@/layouts/common';
import { VerticalMenu } from './components';
const app = useAppStore();
const theme = useThemeStore();
const isHorizontalMix = computed(() => theme.layout.mode === 'horizontal-mix');
const showTitle = computed(() => !app.siderCollapse && theme.layout.mode !== 'vertical-mix');
</script>
<style scoped></style>

View File

@ -1,3 +1,4 @@
import SiderMenu from './SiderMenu.vue'; import VerticalSider from './VerticalSider/index.vue';
import VerticalMixSider from './VerticalMixSider/index.vue';
export { SiderMenu }; export { VerticalSider, VerticalMixSider };

View File

@ -1,18 +1,16 @@
<template> <template>
<dark-mode-container class="global-sider flex-col-stretch h-full"> <vertical-sider v-if="!isVerticalMix" class="global-sider" />
<global-logo :show-title="!app.siderCollapse" :style="{ height: theme.header.height + 'px' }" /> <vertical-mix-sider v-else class="global-sider" />
<sider-menu class="flex-1-hidden" />
</dark-mode-container>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DarkModeContainer } from '@/components'; import { computed } from 'vue';
import { useAppStore, useThemeStore } from '@/store'; import { useThemeStore } from '@/store';
import GlobalLogo from '../GlobalLogo/index.vue'; import { VerticalSider, VerticalMixSider } from './components';
import { SiderMenu } from './components';
const app = useAppStore();
const theme = useThemeStore(); const theme = useThemeStore();
const isVerticalMix = computed(() => theme.layout.mode === 'vertical-mix');
</script> </script>
<style scoped> <style scoped>
.global-sider { .global-sider {

View File

@ -1,23 +1,16 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import { setupAssets, setupInitSvgLogo } from '@/plugins'; import { setupAssets } from '@/plugins';
import { setupRouter } from '@/router'; import { setupRouter } from '@/router';
import { setupStore } from '@/store'; import { setupStore } from '@/store';
import AppProvider from './AppProvider.vue';
import App from './App.vue'; import App from './App.vue';
async function setupApp() { async function setupApp() {
// 初始化加载的svg logo
setupInitSvgLogo('#loadingLogo');
// 引入静态资源 // 引入静态资源
setupAssets(); setupAssets();
// 挂载 appProvider 解决路由守卫Axios中可使用LoadingBarDialogMessage 等之类组件
const appProvider = createApp(AppProvider);
setupStore(appProvider);
appProvider.mount('#appProvider');
const app = createApp(App); const app = createApp(App);
// 挂载pinia状态
setupStore(app); setupStore(app);
// 挂载路由 // 挂载路由

View File

@ -58,6 +58,10 @@ export const useAppStore = defineStore('app-store', {
/** 设置 vertical-mix模式下 侧边栏的固定状态 */ /** 设置 vertical-mix模式下 侧边栏的固定状态 */
setMixSiderIsFixed(isFixed: boolean) { setMixSiderIsFixed(isFixed: boolean) {
this.mixSiderFixed = isFixed; this.mixSiderFixed = isFixed;
},
/** 设置 vertical-mix模式下 侧边栏的固定状态 */
toggleMixSiderFixed() {
this.mixSiderFixed = !this.mixSiderFixed;
} }
} }
}); });

View File

@ -1,102 +0,0 @@
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;
}
/** 获取主题颜色的各种场景对应的颜色 */
function getThemeColors(colors: [ColorType, string][]) {
const colorActions: ColorAction[] = [
{ scene: '', handler: color => color },
{ scene: 'Suppl', handler: color => color },
{ scene: 'Hover', handler: color => getColorPalette(color, 5) },
{ scene: 'Pressed', handler: color => getColorPalette(color, 7) },
{ scene: 'Active', handler: color => addColorAlpha(color, 0.1) }
];
const themeColor: ThemeColor = {};
colors.forEach(color => {
colorActions.forEach(action => {
const [colorType, colorValue] = color;
const colorKey: ColorKey = `${colorType}Color${action.scene}`;
themeColor[colorKey] = action.handler(colorValue);
});
});
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, action: 'add' | 'update' = 'add') {
const keys = Object.keys(themeVars) as ThemeVarsKeys[];
const style: string[] = [];
keys.forEach(key => {
style.push(`--${kebabCase(key)}: ${themeVars[key]}`);
});
const styleStr = style.join(';');
if (action === 'add') {
document.documentElement.style.cssText = styleStr;
} else {
document.documentElement.style.cssText += styleStr;
}
}
/**
* css vars
* @param primaryColor
*/
export function updateThemeCssVarsByPrimary(primaryColor: string) {
const themeColor = getThemeColors([['primary', primaryColor]]);
addThemeCssVarsToHtml(themeColor, 'update');
}
/** windicss 暗黑模式 */
export function handleWindicssDarkMode() {
const DARK_CLASS = 'dark';
function addDarkClass() {
document.documentElement.classList.add(DARK_CLASS);
}
function removeDarkClass() {
document.documentElement.classList.remove(DARK_CLASS);
}
return {
addDarkClass,
removeDarkClass
};
}

View File

@ -1,266 +0,0 @@
import { watch, onUnmounted } from 'vue';
import type { Ref, ComputedRef } from 'vue';
import { useOsTheme } from 'naive-ui';
import { useElementSize } from '@vueuse/core';
import { objectAssign } from '@/utils';
import type { ThemeSetting, ThemeLayoutMode, ThemeTabMode, ThemeAnimateMode } from '@/interface';
import { handleWindicssDarkMode, updateThemeCssVarsByPrimary } from './helpers';
export interface LayoutFunc {
/** 设置布局最小宽度 */
setLayoutMinWidth(minWidth: number): void;
/** 设置布局模式 */
setLayoutMode(mode: ThemeLayoutMode): void;
}
export function useLayoutFunc(layout: ThemeSetting['layout']): LayoutFunc {
function setLayout(data: Partial<ThemeSetting['layout']>) {
objectAssign(layout, data);
}
function setLayoutMinWidth(minWidth: number) {
setLayout({ minWidth });
}
function setLayoutMode(mode: ThemeLayoutMode) {
setLayout({ mode });
}
return {
setLayoutMinWidth,
setLayoutMode
};
}
export interface HeaderFunc {
/** 设置头部高度 */
setHeaderHeight(height: number): void;
/** 设置头部面包屑可见 */
setHeaderCrumbVisible(visible: boolean): void;
/** 设置头部面包屑图标可见 */
setHeaderCrumbIconVisible(visible: boolean): void;
}
export function useHeaderFunc(header: ThemeSetting['header']): HeaderFunc {
function setHeader(data: Partial<ThemeSetting['header']>) {
objectAssign(header, data);
}
function setHeaderHeight(height: number) {
setHeader({ height });
}
function setHeaderCrumbVisible(visible: boolean) {
setHeader({ crumb: { ...header.crumb, visible } });
}
function setHeaderCrumbIconVisible(visible: boolean) {
setHeader({ crumb: { ...header.crumb, showIcon: visible } });
}
return {
setHeaderHeight,
setHeaderCrumbVisible,
setHeaderCrumbIconVisible
};
}
export interface TabFunc {
/** 设置多页签可见 */
setTabVisible(visible: boolean): void;
/** 设置多页签高度 */
setTabHeight(height: number): void;
/** 设置多页签风格 */
setTabMode(mode: ThemeTabMode): void;
/** 设置多页签缓存 */
setTabIsCache(isCache: boolean): void;
}
export function useTabFunc(tab: ThemeSetting['tab']): TabFunc {
function setTab(data: Partial<ThemeSetting['tab']>) {
objectAssign(tab, data);
}
function setTabVisible(visible: boolean) {
setTab({ visible });
}
function setTabHeight(height: number) {
setTab({ height });
}
function setTabMode(mode: ThemeTabMode) {
setTab({ mode });
}
function setTabIsCache(isCache: boolean) {
setTab({ isCache });
}
return {
setTabVisible,
setTabHeight,
setTabMode,
setTabIsCache
};
}
export interface SiderFunc {
/** 侧边栏宽度 */
setSiderWidth(width: number): void;
/** 侧边栏折叠时的宽度 */
setSiderCollapsedWidth(width: number): void;
/** vertical-mix模式下侧边栏宽度 */
setMixSiderWidth(width: number): void;
/** vertical-mix模式下侧边栏折叠时的宽度 */
setMixSiderCollapsedWidth(width: number): void;
/** vertical-mix模式下侧边栏展示子菜单的宽度 */
setMixSiderChildMenuWidth(width: number): void;
}
export function useSiderFunc(sider: ThemeSetting['sider']): SiderFunc {
function setSider(data: Partial<ThemeSetting['sider']>) {
objectAssign(sider, data);
}
function setSiderWidth(width: number) {
setSider({ width });
}
function setSiderCollapsedWidth(width: number) {
setSider({ collapsedWidth: width });
}
function setMixSiderWidth(width: number) {
setSider({ mixWidth: width });
}
function setMixSiderCollapsedWidth(width: number) {
setSider({ mixCollapsedWidth: width });
}
function setMixSiderChildMenuWidth(width: number) {
setSider({ mixChildMenuWidth: width });
}
return {
setSiderWidth,
setSiderCollapsedWidth,
setMixSiderWidth,
setMixSiderCollapsedWidth,
setMixSiderChildMenuWidth
};
}
export interface FooterFunc {
/** 设置底部是否固定 */
setFooterIsFixed(isFixed: boolean): void;
/** 设置底部高度 */
setFooterHeight(height: number): void;
}
export function useFooterFunc(footer: ThemeSetting['footer']): FooterFunc {
function setFooter(data: Partial<ThemeSetting['footer']>) {
objectAssign(footer, data);
}
function setFooterIsFixed(isFixed: boolean) {
setFooter({ fixed: isFixed });
}
function setFooterHeight(height: number) {
setFooter({ height });
}
return {
setFooterIsFixed,
setFooterHeight
};
}
export interface PageFunc {
/** 设置切换页面时是否过渡动画 */
setPageIsAnimate(animate: boolean): void;
/** 设置页面过渡动画类型 */
setPageAnimateMode(mode: ThemeAnimateMode): void;
}
export function usePageFunc(page: ThemeSetting['page']): PageFunc {
function setPage(data: Partial<ThemeSetting['page']>) {
objectAssign(page, data);
}
function setPageIsAnimate(animate: boolean) {
setPage({ animate });
}
function setPageAnimateMode(mode: ThemeAnimateMode) {
setPage({ animateMode: mode });
}
return {
setPageIsAnimate,
setPageAnimateMode
};
}
/**
*
* @param isDark -
*/
type OsThemeCallback = (isDark: boolean) => void;
/** 监听操作系统主题模式 */
export function osThemeWatcher(callback: OsThemeCallback) {
/** 操作系统暗黑主题 */
const osTheme = useOsTheme();
const stopHandle = watch(
osTheme,
newValue => {
const isDark = newValue === 'dark';
callback(isDark);
},
{ immediate: true }
);
onUnmounted(() => {
stopHandle();
});
}
/** 应用windicss的暗黑模式 */
export function setupWindicssDarkMode(darkMode: Ref<boolean>) {
const { addDarkClass, removeDarkClass } = handleWindicssDarkMode();
const stopHandle = watch(
() => darkMode.value,
newValue => {
if (newValue) {
addDarkClass();
} else {
removeDarkClass();
}
},
{ immediate: true }
);
onUnmounted(() => {
stopHandle();
});
}
/**
*
* @description ,,
*/
export function setupHiddenScroll(minWidthOfLayout: ComputedRef<number>) {
const { width } = useElementSize(document.documentElement);
const stopHandle = watch(width, newValue => {
if (newValue < minWidthOfLayout.value) {
document.documentElement.style.overflowX = 'auto';
} else {
document.documentElement.style.overflowX = 'hidden';
}
});
onUnmounted(() => {
stopHandle();
});
}
/**
*
* @param themeColor
*/
export function themeColorWatcher(themeColor: Ref<string>) {
const stopHandle = watch(themeColor, newValue => {
updateThemeCssVarsByPrimary(newValue);
});
onUnmounted(() => {
stopHandle();
});
}

View File

@ -1,229 +0,0 @@
import { ref, reactive, computed } from 'vue';
import type { Ref, ComputedRef } from 'vue';
import { defineStore } from 'pinia';
import { darkTheme } from 'naive-ui';
import type { GlobalThemeOverrides, GlobalTheme } from 'naive-ui';
import { themeSetting } from '@/settings';
import { useBoolean } from '@/hooks';
import { getColorPalette } from '@/utils';
import type { ThemeSetting, ThemeHorizontalMenuPosition } from '@/interface';
import { getNaiveThemeOverrides, addThemeCssVarsToHtml } from './helpers';
import {
useLayoutFunc,
useHeaderFunc,
useTabFunc,
useSiderFunc,
useFooterFunc,
usePageFunc,
osThemeWatcher,
setupWindicssDarkMode,
setupHiddenScroll,
themeColorWatcher
} from './hooks';
import type { LayoutFunc, HeaderFunc, TabFunc, SiderFunc, FooterFunc, PageFunc } from './hooks';
type BuiltInGlobalTheme = Omit<Required<GlobalTheme>, 'InternalSelectMenu' | 'InternalSelection'>;
interface ThemeStore extends LayoutFunc, HeaderFunc, TabFunc, SiderFunc, FooterFunc, PageFunc {
/** 暗黑模式 */
darkMode: Ref<boolean>;
/** 设置暗黑模式 */
setDarkMode(dark: boolean): void;
/** 切换/关闭 暗黑模式 */
toggleDarkMode(): void;
/** 布局样式 */
layout: ThemeSetting['layout'];
/** 主题颜色 */
themeColor: Ref<string>;
/** 设置系统主题颜色 */
setThemeColor(color: string): void;
/** 主题颜色列表 */
themeColorList: string[];
/** 其他颜色 */
otherColor: ComputedRef<ThemeSetting['otherColor']>;
/** 固定头部和多页签 */
fixedHeaderAndTab: Ref<boolean>;
/** 设置固定头部和多页签 */
setIsFixedHeaderAndTab(isFixed: boolean): void;
/** 重载按钮可见 */
reloadVisible: Ref<boolean>;
/** 设置 显示/隐藏 重载按钮 */
setReloadVisible(visible: boolean): void;
/** 头部 */
header: ThemeSetting['header'];
/** 多页签 */
tab: ThemeSetting['tab'];
/** 侧边栏 */
sider: ThemeSetting['sider'];
/** 菜单 */
menu: ThemeSetting['menu'];
/** 设置水平模式的菜单的位置 */
setHorizontalMenuPosition(posiiton: ThemeHorizontalMenuPosition): void;
/** 底部 */
footer: ThemeSetting['footer'];
/** 页面 */
page: ThemeSetting['page'];
/** naiveUI的主题配置 */
naiveThemeOverrides: ComputedRef<GlobalThemeOverrides>;
/** naive-ui暗黑主题 */
naiveTheme: ComputedRef<BuiltInGlobalTheme | undefined>;
/** 重置状态 */
resetThemeStore(): void;
}
export const useThemeStore = defineStore('theme-store', () => {
// 暗黑模式
const { bool: darkMode, setBool: setDarkMode, toggle: toggleDarkMode } = useBoolean();
// 布局
const layout = reactive<ThemeSetting['layout']>({
...themeSetting.layout
});
const { setLayoutMinWidth, setLayoutMode } = useLayoutFunc(layout);
// 主题色
const themeColor = ref(themeSetting.themeColor);
/** 设置系统主题颜色 */
function setThemeColor(color: string) {
themeColor.value = color;
}
const { themeColorList } = themeSetting;
const otherColor = computed<ThemeSetting['otherColor']>(() => ({
...themeSetting.otherColor,
info: getColorPalette(themeColor.value, 7)
}));
// 固定头部和多页签
const { bool: fixedHeaderAndTab, setBool: setIsFixedHeaderAndTab } = useBoolean(themeSetting.fixedHeaderAndTab);
// 重载按钮
const { bool: reloadVisible, setBool: setReloadVisible } = useBoolean(themeSetting.showReload);
// 头部
const header = reactive<ThemeSetting['header']>({
height: themeSetting.header.height,
crumb: { ...themeSetting.header.crumb }
});
const { setHeaderHeight, setHeaderCrumbVisible, setHeaderCrumbIconVisible } = useHeaderFunc(header);
// 多页签
const tab = reactive<ThemeSetting['tab']>({
...themeSetting.tab
});
const { setTabVisible, setTabHeight, setTabMode, setTabIsCache } = useTabFunc(tab);
// 侧边栏
const sider = reactive<ThemeSetting['sider']>({
...themeSetting.sider
});
const {
setSiderWidth,
setSiderCollapsedWidth,
setMixSiderWidth,
setMixSiderCollapsedWidth,
setMixSiderChildMenuWidth
} = useSiderFunc(sider);
// 菜单
const menu = reactive<ThemeSetting['menu']>({
...themeSetting.menu
});
function setHorizontalMenuPosition(posiiton: ThemeHorizontalMenuPosition) {
menu.horizontalPosition = posiiton;
}
// 底部
const footer = reactive<ThemeSetting['footer']>({
...themeSetting.footer
});
const { setFooterIsFixed, setFooterHeight } = useFooterFunc(footer);
// 页面
const page = reactive<ThemeSetting['page']>({
...themeSetting.page
});
const { setPageIsAnimate, setPageAnimateMode } = usePageFunc(page);
// naive主题
const naiveThemeOverrides = computed<GlobalThemeOverrides>(() =>
getNaiveThemeOverrides({ primary: themeColor.value, ...otherColor.value })
);
const naiveTheme = computed(() => (darkMode.value ? darkTheme : undefined));
/** 重置theme状态 */
function resetThemeStore() {
setDarkMode(false);
}
/** 初始化css vars, 并添加至html */
function initThemeCssVars() {
const updatedThemeVars = { ...naiveThemeOverrides.value.common };
addThemeCssVarsToHtml(updatedThemeVars);
}
/** 系统主题适应操作系统 */
function handleAdaptOsTheme() {
osThemeWatcher(isDark => {
if (isDark) {
setDarkMode(true);
} else {
setDarkMode(false);
}
});
}
function init() {
initThemeCssVars();
handleAdaptOsTheme();
setupWindicssDarkMode(darkMode);
setupHiddenScroll(computed(() => layout.minWidth));
themeColorWatcher(themeColor);
}
init();
const themeStore: ThemeStore = {
darkMode,
setDarkMode,
toggleDarkMode,
layout,
setLayoutMinWidth,
setLayoutMode,
themeColor,
setThemeColor,
themeColorList,
otherColor,
fixedHeaderAndTab,
setIsFixedHeaderAndTab,
reloadVisible,
setReloadVisible,
header,
setHeaderHeight,
setHeaderCrumbVisible,
setHeaderCrumbIconVisible,
tab,
setTabVisible,
setTabHeight,
setTabMode,
setTabIsCache,
sider,
setSiderWidth,
setSiderCollapsedWidth,
setMixSiderWidth,
setMixSiderCollapsedWidth,
setMixSiderChildMenuWidth,
menu,
setHorizontalMenuPosition,
footer,
setFooterIsFixed,
setFooterHeight,
page,
setPageIsAnimate,
setPageAnimateMode,
naiveThemeOverrides,
naiveTheme,
resetThemeStore
};
return themeStore;
});

View File

@ -1,3 +1,4 @@
import { watch, onUnmounted } from 'vue';
import { useBodyScroll } from '@/hooks'; import { useBodyScroll } from '@/hooks';
import { useAppStore } from '../modules'; import { useAppStore } from '../modules';
@ -6,8 +7,15 @@ export default function subscribeAppStore() {
const app = useAppStore(); const app = useAppStore();
const { scrollBodyHandler } = useBodyScroll(); const { scrollBodyHandler } = useBodyScroll();
app.$subscribe((_mutation, state) => { // 弹窗打开时禁止滚动条
// 弹窗打开时禁止滚动条 const stopHandle = watch(
scrollBodyHandler(state.settingDrawerVisible); () => app.settingDrawerVisible,
newValue => {
scrollBodyHandler(newValue);
}
);
onUnmounted(() => {
stopHandle();
}); });
} }

View File

@ -1,6 +1,8 @@
import subscribeAppStore from './app'; import subscribeAppStore from './app';
import subscribeThemeStore from './theme';
/** 订阅状态 */ /** 订阅状态 */
export function subscribeStore() { export function subscribeStore() {
subscribeAppStore(); subscribeAppStore();
subscribeThemeStore();
} }

View File

@ -1,17 +1,60 @@
import { watch, onUnmounted } from 'vue';
import { useOsTheme } from 'naive-ui';
import { useElementSize } from '@vueuse/core';
import { EnumStorageKey } from '@/enum';
import { useThemeStore } from '../modules'; import { useThemeStore } from '../modules';
/** 订阅app store */ /** 订阅theme store */
export default function subscribeAppStore() { export default function subscribeThemeStore() {
const theme = useThemeStore(); const theme = useThemeStore();
const osTheme = useOsTheme();
const { width } = useElementSize(document.documentElement);
const { addDarkClass, removeDarkClass } = handleWindicssDarkMode(); const { addDarkClass, removeDarkClass } = handleWindicssDarkMode();
theme.$subscribe((_mutation, state) => { const stopThemeColor = watch(
// 监听暗黑模式 () => theme.themeColor,
if (state.darkMode) { newValue => {
addDarkClass(); window.localStorage.setItem(EnumStorageKey['theme-color'], `--primary-color: ${newValue};`);
} else { },
removeDarkClass(); { immediate: true }
);
// 监听暗黑模式
const stopDarkMode = watch(
() => theme.darkMode,
newValue => {
if (newValue) {
addDarkClass();
} else {
removeDarkClass();
}
} }
);
// 监听操作系统主题模式
const stopOsTheme = watch(
osTheme,
newValue => {
const isDark = newValue === 'dark';
theme.setDarkMode(isDark);
},
{ immediate: true }
);
// 禁用横向滚动(页面切换时,过渡动画会产生水平方向的滚动条, 小于最小宽度时,不禁止)
const stopWidth = watch(width, newValue => {
if (newValue < theme.layout.minWidth) {
document.documentElement.style.overflowX = 'auto';
} else {
document.documentElement.style.overflowX = 'hidden';
}
});
onUnmounted(() => {
stopThemeColor();
stopDarkMode();
stopOsTheme();
stopWidth();
}); });
} }

View File

@ -49,3 +49,24 @@ export function transformAuthRouteToMenu(routes: AuthRoute.Route[]): GlobalMenuO
return globalMenu; return globalMenu;
} }
/**
* paths
* @param activeKey - key
* @param menus -
*/
export function getActiveKeyPathsOfMenus(activeKey: string, menus: GlobalMenuOption[]) {
const keys = menus.map(menu => getActiveKeyPathsOfMenu(activeKey, menu)).flat(1);
return keys;
}
function getActiveKeyPathsOfMenu(activeKey: string, menu: GlobalMenuOption) {
const keys: string[] = [];
if (activeKey.includes(menu.routeName)) {
keys.push(menu.routeName);
}
if (menu.children) {
keys.push(...menu.children.map(item => getActiveKeyPathsOfMenu(activeKey, item)).flat(1));
}
return keys;
}

View File

@ -12,13 +12,13 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"importHelpers": true, "importHelpers": true,
"skipLibCheck": true,
"noEmit": true,
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"],
"~/*": ["./*"] "~/*": ["./*"]
}, },
"lib": ["esnext", "dom", "dom.iterable", "scripthost"], "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
"skipLibCheck": true,
"noEmit": true
}, },
"include": ["vite.config.*", "src/typings/*.d.ts", "src/**/*", "src/**/*.vue", "mock/**/*.ts", "build/**/*.ts", ".env-config.ts"], "include": ["vite.config.*", "src/typings/*.d.ts", "src/**/*", "src/**/*.vue", "mock/**/*.ts", "build/**/*.ts", ".env-config.ts"],
"exclude": ["/dist/**", "node_modules"] "exclude": ["/dist/**", "node_modules"]

View File

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