初始化
This commit is contained in:
146
src/layouts/base-layout/index.vue
Normal file
146
src/layouts/base-layout/index.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import type { LayoutMode } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import GlobalHeader from '../modules/global-header/index.vue';
|
||||
import GlobalSider from '../modules/global-sider/index.vue';
|
||||
import GlobalTab from '../modules/global-tab/index.vue';
|
||||
import GlobalContent from '../modules/global-content/index.vue';
|
||||
import GlobalFooter from '../modules/global-footer/index.vue';
|
||||
import ThemeDrawer from '../modules/theme-drawer/index.vue';
|
||||
import { setupMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({ name: 'BaseLayout' });
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
|
||||
|
||||
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
|
||||
|
||||
const layoutMode = computed(() => {
|
||||
const vertical: LayoutMode = 'vertical';
|
||||
const horizontal: LayoutMode = 'horizontal';
|
||||
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
|
||||
});
|
||||
|
||||
const headerProps = computed(() => {
|
||||
const { mode, reverseHorizontalMix } = themeStore.layout;
|
||||
|
||||
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
||||
vertical: {
|
||||
showLogo: false,
|
||||
showMenu: false,
|
||||
showMenuToggler: true
|
||||
},
|
||||
'vertical-mix': {
|
||||
showLogo: false,
|
||||
showMenu: false,
|
||||
showMenuToggler: false
|
||||
},
|
||||
horizontal: {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'horizontal-mix': {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
|
||||
}
|
||||
};
|
||||
|
||||
return headerPropsConfig[mode];
|
||||
});
|
||||
|
||||
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
|
||||
const siderWidth = computed(() => getSiderWidth());
|
||||
|
||||
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
|
||||
|
||||
function getSiderWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
w += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
function getSiderCollapsedWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
w += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout
|
||||
v-model:sider-collapse="appStore.siderCollapse"
|
||||
:mode="layoutMode"
|
||||
:scroll-el-id="LAYOUT_SCROLL_EL_ID"
|
||||
:scroll-mode="themeStore.layout.scrollMode"
|
||||
:is-mobile="appStore.isMobile"
|
||||
:full-content="appStore.fullContent"
|
||||
:fixed-top="themeStore.fixedHeaderAndTab"
|
||||
:header-height="themeStore.header.height"
|
||||
:tab-visible="themeStore.tab.visible"
|
||||
:tab-height="themeStore.tab.height"
|
||||
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
|
||||
:sider-visible="siderVisible"
|
||||
:sider-width="siderWidth"
|
||||
:sider-collapsed-width="siderCollapsedWidth"
|
||||
:footer-visible="themeStore.footer.visible"
|
||||
:footer-height="themeStore.footer.height"
|
||||
:fixed-footer="themeStore.footer.fixed"
|
||||
:right-footer="themeStore.footer.right"
|
||||
>
|
||||
<template #header>
|
||||
<GlobalHeader v-bind="headerProps" />
|
||||
</template>
|
||||
<template #tab>
|
||||
<GlobalTab />
|
||||
</template>
|
||||
<template #sider>
|
||||
<GlobalSider />
|
||||
</template>
|
||||
<GlobalMenu />
|
||||
<GlobalContent />
|
||||
<ThemeDrawer />
|
||||
<template #footer>
|
||||
<GlobalFooter />
|
||||
</template>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
#__SCROLL_EL_ID__ {
|
||||
@include scrollbar();
|
||||
}
|
||||
</style>
|
||||
11
src/layouts/blank-layout/index.vue
Normal file
11
src/layouts/blank-layout/index.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import GlobalContent from '../modules/global-content/index.vue';
|
||||
|
||||
defineOptions({ name: 'BlankLayout' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GlobalContent :show-padding="false" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
109
src/layouts/context/index.ts
Normal file
109
src/layouts/context/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useContext } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
|
||||
|
||||
function useMixMenu() {
|
||||
const route = useRoute();
|
||||
const routeStore = useRouteStore();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const activeFirstLevelMenuKey = ref('');
|
||||
|
||||
function setActiveFirstLevelMenuKey(key: string) {
|
||||
activeFirstLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelRouteName] = selectedKey.value.split('_');
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelRouteName);
|
||||
}
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
const { children: _, ...rest } = menu;
|
||||
|
||||
return rest;
|
||||
})
|
||||
);
|
||||
|
||||
const childLevelMenus = computed<App.Global.Menu[]>(
|
||||
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
||||
if (!activeFirstLevelMenuKey.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
|
||||
|
||||
return Boolean(findItem?.children?.length);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
allMenus,
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
getActiveFirstLevelMenuKey
|
||||
};
|
||||
}
|
||||
|
||||
export function useMenu() {
|
||||
const route = useRoute();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
|
||||
const selectedKey = computed(() => {
|
||||
const { hideInMenu, activeMenu } = route.meta;
|
||||
const name = route.name as string;
|
||||
|
||||
const routeName = (hideInMenu ? activeMenu : name) || name;
|
||||
|
||||
return routeName;
|
||||
});
|
||||
|
||||
const selectedKeyDummy = ref(selectedKey.value);
|
||||
|
||||
watch(
|
||||
() => selectedKey.value,
|
||||
val => {
|
||||
selectedKeyDummy.value = val;
|
||||
}
|
||||
);
|
||||
|
||||
function handleSelect(key: RouteKey) {
|
||||
selectedKeyDummy.value = key;
|
||||
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
|
||||
if (key.endsWith('-link')) {
|
||||
nextTick(() => {
|
||||
selectedKeyDummy.value = selectedKey.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedKey,
|
||||
selectedKeyDummy,
|
||||
handleSelect
|
||||
};
|
||||
}
|
||||
52
src/layouts/modules/global-breadcrumb/index.vue
Normal file
52
src/layouts/modules/global-breadcrumb/index.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
defineOptions({ name: 'GlobalBreadcrumb' });
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
interface BreadcrumbContentProps {
|
||||
breadcrumb: App.Global.Menu;
|
||||
}
|
||||
|
||||
const [DefineBreadcrumbContent, BreadcrumbContent] = createReusableTemplate<BreadcrumbContentProps>();
|
||||
|
||||
function handleClickMenu(key: RouteKey) {
|
||||
routerPushByKey(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElBreadcrumb v-if="themeStore.header.breadcrumb.visible">
|
||||
<!-- define component start: BreadcrumbContent -->
|
||||
<DefineBreadcrumbContent v-slot="{ breadcrumb }">
|
||||
<div class="i-flex-y-center align-middle">
|
||||
<component :is="breadcrumb.icon" v-if="themeStore.header.breadcrumb.showIcon" class="mr-4px text-icon" />
|
||||
{{ breadcrumb.label }}
|
||||
</div>
|
||||
</DefineBreadcrumbContent>
|
||||
|
||||
<!-- define component end: BreadcrumbContent -->
|
||||
<ElBreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
|
||||
<ElDropdown v-if="item.options?.length" @command="handleClickMenu">
|
||||
<BreadcrumbContent :breadcrumb="item" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem v-for="option in item.options" :key="option.key" :command="option.key">
|
||||
{{ option.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<BreadcrumbContent v-else :breadcrumb="item" />
|
||||
</ElBreadcrumbItem>
|
||||
</ElBreadcrumb>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
56
src/layouts/modules/global-content/index.vue
Normal file
56
src/layouts/modules/global-content/index.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
|
||||
defineOptions({ name: 'GlobalContent' });
|
||||
|
||||
interface Props {
|
||||
/** Show padding for content */
|
||||
showPadding?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showPadding: true
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
const transitionName = computed(() => (themeStore.page.animate ? themeStore.page.animateMode : ''));
|
||||
|
||||
function resetScroll() {
|
||||
const el = document.querySelector(`#${LAYOUT_SCROLL_EL_ID}`);
|
||||
|
||||
el?.scrollTo({ left: 0, top: 0 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition
|
||||
:name="transitionName"
|
||||
mode="out-in"
|
||||
@before-leave="appStore.setContentXScrollable(true)"
|
||||
@after-leave="resetScroll"
|
||||
@after-enter="appStore.setContentXScrollable(false)"
|
||||
>
|
||||
<KeepAlive :include="routeStore.cacheRoutes" :exclude="routeStore.excludeCacheRoutes">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="appStore.reloadFlag"
|
||||
:key="tabStore.getTabIdByRoute(route)"
|
||||
:class="{ 'p-16px': showPadding }"
|
||||
class="flex-grow bg-layout transition-300"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
13
src/layouts/modules/global-footer/index.vue
Normal file
13
src/layouts/modules/global-footer/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'GlobalFooter'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="h-full flex-center">
|
||||
<span>Copyright © 2026 CN-RDMS</span>
|
||||
</DarkModeContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ThemeButton' });
|
||||
|
||||
const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon
|
||||
icon="majesticons:color-swatch-line"
|
||||
:tooltip-content="$t('icon.themeConfig')"
|
||||
@click="appStore.openThemeDrawer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
92
src/layouts/modules/global-header/components/user-avatar.vue
Normal file
92
src/layouts/modules/global-header/components/user-avatar.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { VNode } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useSvgIcon } from '@/hooks/common/icon';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserAvatar' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { routerPushByKey, toLogin } = useRouterPush();
|
||||
const { SvgIconVNode } = useSvgIcon();
|
||||
|
||||
function loginOrRegister() {
|
||||
toLogin();
|
||||
}
|
||||
|
||||
type DropdownKey = 'user-center' | 'logout';
|
||||
|
||||
type DropdownOption = {
|
||||
key: DropdownKey;
|
||||
label: string;
|
||||
icon?: () => VNode;
|
||||
};
|
||||
|
||||
const options = computed(() => {
|
||||
const opts: DropdownOption[] = [
|
||||
{
|
||||
label: $t('common.userCenter'),
|
||||
key: 'user-center',
|
||||
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
label: $t('common.logout'),
|
||||
key: 'logout',
|
||||
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 })
|
||||
}
|
||||
];
|
||||
|
||||
return opts;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
window.$messageBox
|
||||
?.confirm($t('common.logoutConfirm'), $t('common.tip'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
})
|
||||
.then(() => {
|
||||
authStore.resetStore();
|
||||
});
|
||||
}
|
||||
|
||||
function handleDropdown(key: DropdownKey) {
|
||||
if (key === 'logout') {
|
||||
logout();
|
||||
} else {
|
||||
// If your other options are jumps from other routes, they will be directly supported here
|
||||
routerPushByKey(key);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElButton v-if="!authStore.isLogin" text @click="loginOrRegister">
|
||||
{{ $t('page.login.common.loginOrRegister') }}
|
||||
</ElButton>
|
||||
|
||||
<ElDropdown class="px-14px" trigger="click" @command="handleDropdown">
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="{ key, label, icon } in options"
|
||||
:key="key"
|
||||
class="mx-4px my-1px rounded-6px"
|
||||
:icon="icon"
|
||||
:command="key"
|
||||
>
|
||||
{{ label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
<div class="flex items-center">
|
||||
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
|
||||
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
|
||||
</div>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
62
src/layouts/modules/global-header/index.vue
Normal file
62
src/layouts/modules/global-header/index.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from '@vueuse/core';
|
||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import GlobalLogo from '../global-logo/index.vue';
|
||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||
import GlobalSearch from '../global-search/index.vue';
|
||||
import ThemeButton from './components/theme-button.vue';
|
||||
import UserAvatar from './components/user-avatar.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalHeader' });
|
||||
|
||||
interface Props {
|
||||
/** Whether to show the logo */
|
||||
showLogo?: App.Global.HeaderProps['showLogo'];
|
||||
/** Whether to show the menu toggler */
|
||||
showMenuToggler?: App.Global.HeaderProps['showMenuToggler'];
|
||||
/** Whether to show the menu */
|
||||
showMenu?: App.Global.HeaderProps['showMenu'];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { isFullscreen, toggle } = useFullscreen();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="h-full flex-y-center px-12px shadow-header">
|
||||
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: themeStore.sider.width + 'px' }" />
|
||||
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
|
||||
<div v-if="showMenu" :id="GLOBAL_HEADER_MENU_ID" class="h-full flex-y-center flex-1-hidden"></div>
|
||||
<div v-else class="h-full flex-y-center flex-1-hidden">
|
||||
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
|
||||
</div>
|
||||
<div class="h-full flex-y-center justify-end">
|
||||
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
|
||||
<div>
|
||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||
</div>
|
||||
<LangSwitch
|
||||
v-if="themeStore.header.multilingual.visible"
|
||||
:lang="appStore.locale"
|
||||
:lang-options="appStore.localeOptions"
|
||||
@change-lang="appStore.changeLocale"
|
||||
/>
|
||||
<ThemeSchemaSwitch
|
||||
:theme-schema="themeStore.themeScheme"
|
||||
:is-dark="themeStore.darkMode"
|
||||
@switch="themeStore.toggleThemeScheme"
|
||||
/>
|
||||
<div>
|
||||
<ThemeButton />
|
||||
</div>
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</DarkModeContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
25
src/layouts/modules/global-logo/index.vue
Normal file
25
src/layouts/modules/global-logo/index.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'GlobalLogo' });
|
||||
|
||||
interface Props {
|
||||
/** Whether to show the title */
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showTitle: true
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
||||
<SystemLogo class="text-32px text-primary" />
|
||||
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
||||
{{ $t('system.title') }}
|
||||
</h2>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
106
src/layouts/modules/global-menu/components/first-level-menu.vue
Normal file
106
src/layouts/modules/global-menu/components/first-level-menu.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { transformColorWithOpacity } from '@sa/color';
|
||||
|
||||
defineOptions({ name: 'FirstLevelMenu' });
|
||||
|
||||
interface Props {
|
||||
menus: App.Global.Menu[];
|
||||
activeMenuKey?: string;
|
||||
inverted?: boolean;
|
||||
siderCollapse?: boolean;
|
||||
darkMode?: boolean;
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', menu: App.Global.Menu): boolean;
|
||||
(e: 'toggleSiderCollapse'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
interface MixMenuItemProps {
|
||||
/** Menu item label */
|
||||
label: App.Global.Menu['label'];
|
||||
/** Menu item icon */
|
||||
icon: App.Global.Menu['icon'];
|
||||
/** Active menu item */
|
||||
active: boolean;
|
||||
/** Mini size */
|
||||
isMini?: boolean;
|
||||
}
|
||||
const [DefineMixMenuItem, MixMenuItem] = createReusableTemplate<MixMenuItemProps>();
|
||||
|
||||
const selectedBgColor = computed(() => {
|
||||
const { darkMode, themeColor } = props;
|
||||
|
||||
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff');
|
||||
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000');
|
||||
|
||||
return darkMode ? dark : light;
|
||||
});
|
||||
|
||||
function handleClickMixMenu(menu: App.Global.Menu) {
|
||||
emit('select', menu);
|
||||
}
|
||||
|
||||
function toggleSiderCollapse() {
|
||||
emit('toggleSiderCollapse');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- define component: MixMenuItem -->
|
||||
<DefineMixMenuItem v-slot="{ label, icon, active, isMini }">
|
||||
<div
|
||||
class="mx-4px mb-6px flex-col-center cursor-pointer rounded-8px bg-transparent px-4px py-8px transition-300 hover:bg-[rgb(0,0,0,0.08)]"
|
||||
:class="{
|
||||
'text-primary selected-mix-menu': active,
|
||||
'text-white:65 hover:text-white': inverted,
|
||||
'!text-white !bg-primary': active && inverted
|
||||
}"
|
||||
>
|
||||
<component :is="icon" :class="[isMini ? 'text-icon-small' : 'text-icon-large']" />
|
||||
<p
|
||||
class="w-full ellipsis-text text-center text-12px transition-height-300"
|
||||
:class="[isMini ? 'h-0 pt-0' : 'h-20px pt-4px']"
|
||||
>
|
||||
{{ label }}
|
||||
</p>
|
||||
</div>
|
||||
</DefineMixMenuItem>
|
||||
<!-- define component end: MixMenuItem -->
|
||||
|
||||
<div class="h-full flex-col-stretch flex-1-hidden">
|
||||
<slot></slot>
|
||||
<SimpleScrollbar>
|
||||
<MixMenuItem
|
||||
v-for="menu in menus"
|
||||
:key="menu.key"
|
||||
:label="menu.label"
|
||||
:icon="menu.icon"
|
||||
:active="menu.key === activeMenuKey"
|
||||
:is-mini="siderCollapse"
|
||||
@click="handleClickMixMenu(menu)"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
<MenuToggler
|
||||
arrow-icon
|
||||
:collapsed="siderCollapse"
|
||||
:z-index="99"
|
||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
||||
@click="toggleSiderCollapse"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selected-mix-menu {
|
||||
background-color: v-bind(selectedBgColor);
|
||||
}
|
||||
</style>
|
||||
36
src/layouts/modules/global-menu/components/menu-item.vue
Normal file
36
src/layouts/modules/global-menu/components/menu-item.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
item: App.Global.Menu;
|
||||
}
|
||||
|
||||
const { item } = defineProps<Props>();
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSubMenu v-if="hasChildren" :index="item.key">
|
||||
<template #title>
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
<span class="ib-ellipsis">{{ item.label }}</span>
|
||||
</template>
|
||||
<MenuItem v-for="child in item.children" :key="child.key" :item="child" :index="child.key"></MenuItem>
|
||||
</ElSubMenu>
|
||||
<ElMenuItem v-else>
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
<span class="ib-ellipsis">{{ item.label }}</span>
|
||||
</ElMenuItem>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ib-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
37
src/layouts/modules/global-menu/index.vue
Normal file
37
src/layouts/modules/global-menu/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import VerticalMenu from './modules/vertical-menu.vue';
|
||||
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
|
||||
import HorizontalMenu from './modules/horizontal-menu.vue';
|
||||
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
|
||||
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalMenu'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
|
||||
vertical: VerticalMenu,
|
||||
'vertical-mix': VerticalMixMenu,
|
||||
horizontal: HorizontalMenu,
|
||||
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
|
||||
};
|
||||
|
||||
return menuMap[themeStore.layout.mode];
|
||||
});
|
||||
|
||||
const reRenderVertical = computed(() => themeStore.layout.mode === 'vertical' && appStore.isMobile);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="activeMenu" :key="reRenderVertical" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
28
src/layouts/modules/global-menu/modules/horizontal-menu.vue
Normal file
28
src/layouts/modules/global-menu/modules/horizontal-menu.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useMenu } from '../../../context';
|
||||
import MenuItem from '../components/menu-item.vue';
|
||||
|
||||
defineOptions({ name: 'HorizontalMenu' });
|
||||
|
||||
const routeStore = useRouteStore();
|
||||
const { selectedKeyDummy, handleSelect } = useMenu();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<ElMenu
|
||||
ellipsis
|
||||
class="w-full"
|
||||
mode="horizontal"
|
||||
:default-active="selectedKeyDummy"
|
||||
@select="val => handleSelect(val as RouteKey)"
|
||||
>
|
||||
<MenuItem v-for="item in routeStore.menus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import MenuItem from '../components/menu-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalMixMenu'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
|
||||
const { selectedKeyDummy, handleSelect } = useMenu();
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
if (!menu.children?.length) {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<ElMenu
|
||||
ellipsis
|
||||
class="w-full"
|
||||
mode="horizontal"
|
||||
:default-active="selectedKeyDummy"
|
||||
@select="val => handleSelect(val as RouteKey)"
|
||||
>
|
||||
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
// import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import MenuItem from '../components/menu-item.vue';
|
||||
|
||||
defineOptions({ name: 'ReversedHorizontalMixMenu' });
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
// const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
isActiveFirstLevelMenuHasChildren
|
||||
} = useMixMenuContext();
|
||||
const { selectedKey, selectedKeyDummy, handleSelect } = useMenu();
|
||||
|
||||
function handleSelectMixMenu(key: RouteKey) {
|
||||
setActiveFirstLevelMenuKey(key);
|
||||
|
||||
if (!isActiveFirstLevelMenuHasChildren.value) {
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
}
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<ElMenu
|
||||
ellipsis
|
||||
class="w-full"
|
||||
mode="horizontal"
|
||||
:default-active="activeFirstLevelMenuKey"
|
||||
@select="val => handleSelectMixMenu(val as RouteKey)"
|
||||
>
|
||||
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<SimpleScrollbar>
|
||||
<ElMenu
|
||||
mode="vertical"
|
||||
:default-active="selectedKeyDummy"
|
||||
:collapse="appStore.siderCollapse"
|
||||
@select="val => handleSelect(val as RouteKey)"
|
||||
>
|
||||
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
</SimpleScrollbar>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
57
src/layouts/modules/global-menu/modules/vertical-menu.vue
Normal file
57
src/layouts/modules/global-menu/modules/vertical-menu.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useMenu } from '../../../context';
|
||||
import MenuItem from '../components/menu-item.vue';
|
||||
|
||||
defineOptions({ name: 'VerticalMenu' });
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { selectedKey, selectedKeyDummy, handleSelect } = useMenu();
|
||||
|
||||
// const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<SimpleScrollbar>
|
||||
<ElMenu
|
||||
mode="vertical"
|
||||
:default-active="selectedKeyDummy"
|
||||
:default-openeds="expandedKeys"
|
||||
:collapse="appStore.siderCollapse"
|
||||
:collapse-transition="false"
|
||||
@select="val => handleSelect(val as RouteKey)"
|
||||
>
|
||||
<MenuItem v-for="item in routeStore.menus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
</SimpleScrollbar>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
124
src/layouts/modules/global-menu/modules/vertical-mix-menu.vue
Normal file
124
src/layouts/modules/global-menu/modules/vertical-mix-menu.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { $t } from '@/locales';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import GlobalLogo from '../../global-logo/index.vue';
|
||||
import MenuItem from '../components/menu-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalMixMenu'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
allMenus,
|
||||
childLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
getActiveFirstLevelMenuKey
|
||||
//
|
||||
} = useMixMenuContext();
|
||||
const { selectedKey, selectedKeyDummy, handleSelect } = useMenu();
|
||||
|
||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
|
||||
|
||||
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
|
||||
|
||||
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
if (menu.children?.length) {
|
||||
setDrawerVisible(true);
|
||||
} else {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetActiveMenu() {
|
||||
setDrawerVisible(false);
|
||||
|
||||
if (!appStore.mixSiderFixed) {
|
||||
getActiveFirstLevelMenuKey();
|
||||
}
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:inverted="inverted"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
>
|
||||
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
|
||||
</FirstLevelMenu>
|
||||
<div
|
||||
class="relative h-full transition-width-300"
|
||||
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
|
||||
>
|
||||
<DarkModeContainer
|
||||
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
|
||||
:inverted="inverted"
|
||||
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
|
||||
>
|
||||
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
|
||||
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
|
||||
<PinToggler
|
||||
:pin="appStore.mixSiderFixed"
|
||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
||||
@click="appStore.toggleMixSiderFixed"
|
||||
/>
|
||||
</header>
|
||||
<SimpleScrollbar>
|
||||
<ElMenu mode="vertical" :default-active="selectedKeyDummy" @select="val => handleSelect(val as RouteKey)">
|
||||
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
</SimpleScrollbar>
|
||||
</DarkModeContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'SearchFooter' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-44px flex-y-center gap-14px px-24px">
|
||||
<span class="flex-y-center">
|
||||
<icon-mdi-keyboard-return class="operate-shadow operate-item" />
|
||||
<span>{{ $t('common.confirm') }}</span>
|
||||
</span>
|
||||
<span class="flex-y-center">
|
||||
<icon-mdi-arrow-up-thin class="operate-shadow operate-item" />
|
||||
<icon-mdi-arrow-down-thin class="operate-shadow operate-item" />
|
||||
<span>{{ $t('common.switch') }}</span>
|
||||
</span>
|
||||
<span class="flex-y-center">
|
||||
<icon-mdi-keyboard-esc class="operate-shadow operate-item" />
|
||||
<span>{{ $t('common.close') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operate-shadow {
|
||||
box-shadow:
|
||||
inset 0 -2px #cdcde6,
|
||||
inset 0 0 1px 1px #fff,
|
||||
0 1px 2px 1px #1e235a66;
|
||||
}
|
||||
|
||||
.operate-item {
|
||||
--uno: mr-6px p-2px text-20px;
|
||||
}
|
||||
</style>
|
||||
150
src/layouts/modules/global-search/components/search-modal.vue
Normal file
150
src/layouts/modules/global-search/components/search-modal.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
|
||||
import type { InputInstance } from 'element-plus';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import SearchResult from './search-result.vue';
|
||||
import SearchFooter from './search-footer.vue';
|
||||
|
||||
defineOptions({ name: 'SearchModal' });
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const routeStore = useRouteStore();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const keyword = ref('');
|
||||
const activePath = ref('');
|
||||
const resultOptions = shallowRef<App.Global.Menu[]>([]);
|
||||
|
||||
const handleSearch = useDebounceFn(search, 300);
|
||||
|
||||
const visible = defineModel<boolean>('show', { required: true });
|
||||
|
||||
const searchInput = ref<InputInstance>();
|
||||
|
||||
function search() {
|
||||
resultOptions.value = routeStore.searchMenus.filter(menu => {
|
||||
const trimKeyword = keyword.value.toLocaleLowerCase().trim();
|
||||
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase();
|
||||
return trimKeyword && title.includes(trimKeyword);
|
||||
});
|
||||
|
||||
activePath.value = resultOptions.value[0]?.routePath ?? '';
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// handle with setTimeout to prevent user from seeing some operations
|
||||
setTimeout(() => {
|
||||
visible.value = false;
|
||||
resultOptions.value = [];
|
||||
keyword.value = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** key up */
|
||||
function handleUp() {
|
||||
const { length } = resultOptions.value;
|
||||
if (length === 0) return;
|
||||
|
||||
const index = getActivePathIndex();
|
||||
if (index === -1) return;
|
||||
|
||||
const activeIndex = index === 0 ? length - 1 : index - 1;
|
||||
|
||||
activePath.value = resultOptions.value[activeIndex].routePath;
|
||||
}
|
||||
|
||||
/** key down */
|
||||
function handleDown() {
|
||||
const { length } = resultOptions.value;
|
||||
if (length === 0) return;
|
||||
|
||||
const index = getActivePathIndex();
|
||||
if (index === -1) return;
|
||||
|
||||
const activeIndex = index === length - 1 ? 0 : index + 1;
|
||||
|
||||
activePath.value = resultOptions.value[activeIndex].routePath;
|
||||
}
|
||||
|
||||
function getActivePathIndex() {
|
||||
return resultOptions.value.findIndex(item => item.routePath === activePath.value);
|
||||
}
|
||||
|
||||
/** key enter */
|
||||
function handleEnter() {
|
||||
if (resultOptions.value?.length === 0 || activePath.value === '') return;
|
||||
handleClose();
|
||||
router.push(activePath.value);
|
||||
}
|
||||
|
||||
function registerShortcut() {
|
||||
onKeyStroke('Escape', handleClose);
|
||||
onKeyStroke('Enter', handleEnter);
|
||||
onKeyStroke('ArrowUp', handleUp);
|
||||
onKeyStroke('ArrowDown', handleDown);
|
||||
}
|
||||
|
||||
/** open dialog and set input focus */
|
||||
function setFocus() {
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
registerShortcut();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
class="search-modal fixed left-0 right-0"
|
||||
:class="[isMobile ? 'size-full top-0px rounded-0' : 'w-630px top-50px']"
|
||||
@open-auto-focus="setFocus"
|
||||
@close="handleClose"
|
||||
>
|
||||
<ElInput
|
||||
ref="searchInput"
|
||||
v-model="keyword"
|
||||
clearable
|
||||
:placeholder="$t('common.keywordSearch')"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-uil-search class="text-15px" />
|
||||
</template>
|
||||
<template v-if="isMobile" #append>
|
||||
<ElButton type="primary" plain @click="handleClose">{{ $t('common.cancel') }}</ElButton>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<div>
|
||||
<ElEmpty v-if="resultOptions.length === 0" :description="$t('common.noData')" :image-size="50" />
|
||||
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<SearchFooter v-if="!isMobile" />
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.search-modal {
|
||||
.el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding: 10px 15px 0;
|
||||
}
|
||||
.el-dialog__footer {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'SearchResult' });
|
||||
|
||||
interface Props {
|
||||
options: App.Global.Menu[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'enter'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const active = defineModel<string>('path', { required: true });
|
||||
|
||||
async function handleMouseEnter(item: App.Global.Menu) {
|
||||
active.value = item.routePath;
|
||||
}
|
||||
|
||||
function handleTo() {
|
||||
emit('enter');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElScrollbar>
|
||||
<div class="pb-12px">
|
||||
<template v-for="item in options" :key="item.routePath">
|
||||
<div
|
||||
class="mt-8px h-56px flex-y-center cursor-pointer justify-between rounded-4px bg-#e5e7eb px-14px dark:bg-dark"
|
||||
:style="{
|
||||
background: item.routePath === active ? theme.themeColor : '',
|
||||
color: item.routePath === active ? '#fff' : ''
|
||||
}"
|
||||
@click="handleTo"
|
||||
@mouseenter="handleMouseEnter(item)"
|
||||
>
|
||||
<component :is="item.icon" />
|
||||
<span class="ml-5px flex-1">
|
||||
{{ (item.i18nKey && $t(item.i18nKey)) || item.label }}
|
||||
</span>
|
||||
<icon-ant-design-enter-outlined class="icon mr-3px p-2px text-20px" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
18
src/layouts/modules/global-search/index.vue
Normal file
18
src/layouts/modules/global-search/index.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { $t } from '@/locales';
|
||||
import SearchModal from './components/search-modal.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalSearch' });
|
||||
|
||||
const { bool: show, toggle } = useBoolean();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon :tooltip-content="$t('common.search')" @click="toggle">
|
||||
<icon-uil-search />
|
||||
</ButtonIcon>
|
||||
<SearchModal v-model:show="show" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
31
src/layouts/modules/global-sider/index.vue
Normal file
31
src/layouts/modules/global-sider/index.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import GlobalLogo from '../global-logo/index.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalSider' });
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
|
||||
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
|
||||
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="size-full flex-col-stretch shadow-sider" :inverted="darkMenu">
|
||||
<GlobalLogo
|
||||
v-if="showLogo"
|
||||
:show-title="!appStore.siderCollapse"
|
||||
:style="{ height: themeStore.header.height + 'px' }"
|
||||
/>
|
||||
<div :id="GLOBAL_SIDER_MENU_ID" :class="menuWrapperClass"></div>
|
||||
</DarkModeContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
149
src/layouts/modules/global-tab/context-menu.vue
Normal file
149
src/layouts/modules/global-tab/context-menu.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { VNode } from 'vue';
|
||||
import type { DropdownInstance } from 'element-plus';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
import { useSvgIcon } from '@/hooks/common/icon';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ContextMenu' });
|
||||
|
||||
interface Props {
|
||||
/** ClientX */
|
||||
x: number;
|
||||
/** ClientY */
|
||||
y: number;
|
||||
tabId: string;
|
||||
excludeKeys?: App.Global.DropdownKey[];
|
||||
disabledKeys?: App.Global.DropdownKey[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
excludeKeys: () => [],
|
||||
disabledKeys: () => []
|
||||
});
|
||||
|
||||
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs } = useTabStore();
|
||||
const { SvgIconVNode } = useSvgIcon();
|
||||
|
||||
type DropdownOption = {
|
||||
key: App.Global.DropdownKey;
|
||||
label: string;
|
||||
icon?: () => VNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const options = computed(() => {
|
||||
const opts: DropdownOption[] = [
|
||||
{
|
||||
key: 'closeCurrent',
|
||||
label: $t('dropdown.closeCurrent'),
|
||||
icon: SvgIconVNode({ icon: 'ant-design:close-outlined', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeOther',
|
||||
label: $t('dropdown.closeOther'),
|
||||
icon: SvgIconVNode({ icon: 'ant-design:column-width-outlined', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeLeft',
|
||||
label: $t('dropdown.closeLeft'),
|
||||
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-left', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeRight',
|
||||
label: $t('dropdown.closeRight'),
|
||||
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-right', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeAll',
|
||||
label: $t('dropdown.closeAll'),
|
||||
icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 })
|
||||
}
|
||||
];
|
||||
const { excludeKeys, disabledKeys } = props;
|
||||
|
||||
const result = opts.filter(opt => !excludeKeys.includes(opt.key));
|
||||
|
||||
disabledKeys.forEach(key => {
|
||||
const opt = result.find(item => item.key === key);
|
||||
|
||||
if (opt) {
|
||||
opt.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible');
|
||||
|
||||
const dropdown = ref<DropdownInstance>();
|
||||
|
||||
watch(visible, val => {
|
||||
if (val) {
|
||||
dropdown.value!.handleOpen();
|
||||
} else {
|
||||
dropdown.value!.handleClose();
|
||||
}
|
||||
});
|
||||
|
||||
function hideDropdown() {
|
||||
visible.value = false;
|
||||
dropdown.value!.handleClose();
|
||||
}
|
||||
|
||||
const dropdownAction: Record<App.Global.DropdownKey, () => void> = {
|
||||
closeCurrent() {
|
||||
removeTab(props.tabId);
|
||||
},
|
||||
closeOther() {
|
||||
clearTabs([props.tabId]);
|
||||
},
|
||||
closeLeft() {
|
||||
clearLeftTabs(props.tabId);
|
||||
},
|
||||
closeRight() {
|
||||
clearRightTabs(props.tabId);
|
||||
},
|
||||
closeAll() {
|
||||
clearTabs();
|
||||
}
|
||||
};
|
||||
|
||||
function handleDropdown(optionKey: App.Global.DropdownKey) {
|
||||
dropdownAction[optionKey]?.();
|
||||
hideDropdown();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute" :style="{ top: `${y - 60}px`, left: `${x + 60}px` }">
|
||||
<ElDropdown ref="dropdown" popper-class="arrow-hide" trigger="click" @command="handleDropdown">
|
||||
<!-- Avoid waning: [ElOnlyChild] no valid child node found -->
|
||||
<span></span>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="{ key, label, icon, disabled } in options"
|
||||
:key="key"
|
||||
class="mx-4px my-1px rounded-6px"
|
||||
:icon="icon"
|
||||
:command="key"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.arrow-hide {
|
||||
.el-popper__arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
208
src/layouts/modules/global-tab/index.vue
Normal file
208
src/layouts/modules/global-tab/index.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useElementBounding } from '@vueuse/core';
|
||||
import { PageTab } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
import { isPC } from '@/utils/agent';
|
||||
import BetterScroll from '@/components/custom/better-scroll.vue';
|
||||
import ContextMenu from './context-menu.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalTab' });
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
const bsWrapper = ref<HTMLElement>();
|
||||
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapper);
|
||||
const bsScroll = ref<InstanceType<typeof BetterScroll>>();
|
||||
const tabRef = ref<HTMLElement>();
|
||||
const isPCFlag = isPC();
|
||||
|
||||
const TAB_DATA_ID = 'data-tab-id';
|
||||
|
||||
type TabNamedNodeMap = NamedNodeMap & {
|
||||
[TAB_DATA_ID]: Attr;
|
||||
};
|
||||
|
||||
async function scrollToActiveTab() {
|
||||
await nextTick();
|
||||
if (!tabRef.value) return;
|
||||
|
||||
const { children } = tabRef.value;
|
||||
|
||||
for (let i = 0; i < children.length; i += 1) {
|
||||
const child = children[i];
|
||||
|
||||
const { value: tabId } = (child.attributes as TabNamedNodeMap)[TAB_DATA_ID];
|
||||
|
||||
if (tabId === tabStore.activeTabId) {
|
||||
const { left, width } = child.getBoundingClientRect();
|
||||
const clientX = left + width / 2;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollByClientX(clientX);
|
||||
}, 50);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollByClientX(clientX: number) {
|
||||
const currentX = clientX - bsWrapperLeft.value;
|
||||
const deltaX = currentX - bsWrapperWidth.value / 2;
|
||||
|
||||
if (bsScroll.value?.instance) {
|
||||
const { maxScrollX, x: leftX, scrollBy } = bsScroll.value.instance;
|
||||
|
||||
const rightX = maxScrollX - leftX;
|
||||
const update = deltaX > 0 ? Math.max(-deltaX, rightX) : Math.min(-deltaX, -leftX);
|
||||
|
||||
scrollBy(update, 0, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function getContextMenuDisabledKeys(tabId: string) {
|
||||
const disabledKeys: App.Global.DropdownKey[] = [];
|
||||
|
||||
if (tabStore.isTabRetain(tabId)) {
|
||||
const homeDisable: App.Global.DropdownKey[] = ['closeCurrent', 'closeLeft'];
|
||||
disabledKeys.push(...homeDisable);
|
||||
}
|
||||
|
||||
return disabledKeys;
|
||||
}
|
||||
|
||||
function handleCloseTab(tab: App.Global.Tab) {
|
||||
tabStore.removeTab(tab.id);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
appStore.reloadPage(500);
|
||||
}
|
||||
|
||||
interface DropdownConfig {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
const dropdown = ref<DropdownConfig>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
tabId: ''
|
||||
});
|
||||
|
||||
function setDropdown(config: Partial<DropdownConfig>) {
|
||||
Object.assign(dropdown.value, config);
|
||||
}
|
||||
|
||||
let isClickContextMenu = false;
|
||||
|
||||
function handleDropdownVisible(visible: boolean | undefined) {
|
||||
if (!isClickContextMenu) {
|
||||
setDropdown({ visible });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContextMenu(e: MouseEvent, tabId: string) {
|
||||
e.preventDefault();
|
||||
|
||||
const { clientX, clientY } = e;
|
||||
|
||||
isClickContextMenu = true;
|
||||
|
||||
const DURATION = dropdown.value.visible ? 150 : 0;
|
||||
|
||||
setDropdown({ visible: false });
|
||||
|
||||
setTimeout(() => {
|
||||
setDropdown({
|
||||
visible: true,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
tabId
|
||||
});
|
||||
isClickContextMenu = false;
|
||||
}, DURATION);
|
||||
}
|
||||
|
||||
function init() {
|
||||
tabStore.initTabStore(route);
|
||||
}
|
||||
|
||||
function removeFocus() {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
|
||||
// watch
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
tabStore.addTab(route);
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => tabStore.activeTabId,
|
||||
() => {
|
||||
scrollToActiveTab();
|
||||
}
|
||||
);
|
||||
|
||||
// init
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="size-full flex-y-center px-16px shadow-tab">
|
||||
<div ref="bsWrapper" class="h-full flex-1-hidden">
|
||||
<BetterScroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: !isPCFlag }" @click="removeFocus">
|
||||
<div
|
||||
ref="tabRef"
|
||||
class="h-full flex pr-18px"
|
||||
:class="[themeStore.tab.mode === 'chrome' ? 'items-end' : 'items-center gap-12px']"
|
||||
>
|
||||
<PageTab
|
||||
v-for="tab in tabStore.tabs"
|
||||
:key="tab.id"
|
||||
:[TAB_DATA_ID]="tab.id"
|
||||
:mode="themeStore.tab.mode"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:active="tab.id === tabStore.activeTabId"
|
||||
:active-color="themeStore.themeColor"
|
||||
:closable="!tabStore.isTabRetain(tab.id)"
|
||||
@click="tabStore.switchRouteByTab(tab)"
|
||||
@close="handleCloseTab(tab)"
|
||||
@contextmenu="handleContextMenu($event, tab.id)"
|
||||
>
|
||||
<template #prefix>
|
||||
<SvgIcon :icon="tab.icon" :local-icon="tab.localIcon" class="inline-block align-text-bottom text-16px" />
|
||||
</template>
|
||||
<div class="max-w-240px ellipsis-text">{{ tab.label }}</div>
|
||||
</PageTab>
|
||||
</div>
|
||||
</BetterScroll>
|
||||
</div>
|
||||
<div>
|
||||
<ReloadButton :loading="!appStore.reloadFlag" @click="refresh" />
|
||||
</div>
|
||||
<FullScreen :full="appStore.fullContent" @click="appStore.toggleFullContent" />
|
||||
</DarkModeContainer>
|
||||
<ContextMenu
|
||||
:visible="dropdown.visible"
|
||||
:tab-id="dropdown.tabId"
|
||||
:disabled-keys="getContextMenuDisabledKeys(dropdown.tabId)"
|
||||
:x="dropdown.x"
|
||||
:y="dropdown.y"
|
||||
@update:visible="handleDropdownVisible"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import type { Placement } from 'element-plus';
|
||||
import { themeLayoutModeRecord } from '@/constants/app';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'LayoutModeCard' });
|
||||
|
||||
interface Props {
|
||||
/** Layout mode */
|
||||
mode: UnionKey.ThemeLayoutMode;
|
||||
/** Disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
/** Layout mode change */
|
||||
(e: 'update:mode', mode: UnionKey.ThemeLayoutMode): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
type LayoutConfig = Record<
|
||||
UnionKey.ThemeLayoutMode,
|
||||
{
|
||||
placement: Placement;
|
||||
headerClass: string;
|
||||
menuClass: string;
|
||||
mainClass: string;
|
||||
}
|
||||
>;
|
||||
|
||||
const layoutConfig: LayoutConfig = {
|
||||
vertical: {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-1/3 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'vertical-mix': {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-1/4 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
horizontal: {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-full h-3/4'
|
||||
},
|
||||
'horizontal-mix': {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
}
|
||||
};
|
||||
|
||||
function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
|
||||
if (props.disabled) return;
|
||||
|
||||
emit('update:mode', mode);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-center flex-wrap gap-x-32px gap-y-16px">
|
||||
<div
|
||||
v-for="(item, key) in layoutConfig"
|
||||
:key="key"
|
||||
class="flex cursor-pointer border-2px rounded-6px hover:border-primary"
|
||||
:class="[mode === key ? 'border-primary' : 'border-transparent']"
|
||||
@click="handleChangeMode(key)"
|
||||
>
|
||||
<ElTooltip :placement="item.placement">
|
||||
<template #content>
|
||||
{{ $t(themeLayoutModeRecord[key]) }}
|
||||
</template>
|
||||
<div
|
||||
class="h-64px w-96px gap-6px rd-4px p-6px shadow dark:shadow-coolGray-5"
|
||||
:class="[key.includes('vertical') ? 'flex' : 'flex-col']"
|
||||
>
|
||||
<slot :name="key"></slot>
|
||||
</div>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
22
src/layouts/modules/theme-drawer/components/setting-item.vue
Normal file
22
src/layouts/modules/theme-drawer/components/setting-item.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SettingItem' });
|
||||
|
||||
interface Props {
|
||||
/** Label */
|
||||
label: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full flex-y-center justify-between">
|
||||
<div>
|
||||
<span class="pr-8px text-base-text">{{ label }}</span>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
27
src/layouts/modules/theme-drawer/index.vue
Normal file
27
src/layouts/modules/theme-drawer/index.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import DarkMode from './modules/dark-mode.vue';
|
||||
import LayoutMode from './modules/layout-mode.vue';
|
||||
import ThemeColor from './modules/theme-color.vue';
|
||||
import PageFun from './modules/page-fun.vue';
|
||||
import ConfigOperation from './modules/config-operation.vue';
|
||||
|
||||
defineOptions({ name: 'ThemeDrawer' });
|
||||
|
||||
const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer v-model="appStore.themeDrawerVisible" :title="$t('theme.themeDrawerTitle')" :size="360">
|
||||
<DarkMode />
|
||||
<LayoutMode />
|
||||
<ThemeColor />
|
||||
<PageFun />
|
||||
<template #footer>
|
||||
<ConfigOperation />
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Clipboard from 'clipboard';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ConfigOperation' });
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const domRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function initClipboard() {
|
||||
if (!domRef.value) return;
|
||||
|
||||
const clipboard = new Clipboard(domRef.value);
|
||||
|
||||
clipboard.on('success', () => {
|
||||
window.$message?.success($t('theme.configOperation.copySuccessMsg'));
|
||||
});
|
||||
}
|
||||
|
||||
function getClipboardText() {
|
||||
const reg = /"\w+":/g;
|
||||
|
||||
const json = themeStore.settingsJson;
|
||||
|
||||
return json.replace(reg, match => match.replace(/"/g, ''));
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
themeStore.resetStore();
|
||||
|
||||
setTimeout(() => {
|
||||
window.$message?.success($t('theme.configOperation.resetSuccessMsg'));
|
||||
}, 50);
|
||||
}
|
||||
|
||||
const dataClipboardText = computed(() => getClipboardText());
|
||||
|
||||
onMounted(() => {
|
||||
initClipboard();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full flex justify-between">
|
||||
<textarea id="themeConfigCopyTarget" v-model="dataClipboardText" class="absolute opacity-0 -z-1" />
|
||||
<ElButton type="danger" plain @click="handleReset">{{ $t('theme.configOperation.resetConfig') }}</ElButton>
|
||||
<div ref="domRef" data-clipboard-target="#themeConfigCopyTarget">
|
||||
<ElButton type="primary">{{ $t('theme.configOperation.copyConfig') }}</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
69
src/layouts/modules/theme-drawer/modules/dark-mode.vue
Normal file
69
src/layouts/modules/theme-drawer/modules/dark-mode.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { themeSchemaRecord } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
|
||||
defineOptions({ name: 'DarkMode' });
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const icons: Record<UnionKey.ThemeScheme, string> = {
|
||||
light: 'material-symbols:sunny',
|
||||
dark: 'material-symbols:nightlight-rounded',
|
||||
auto: 'material-symbols:hdr-auto'
|
||||
};
|
||||
|
||||
function handleSegmentChange(value: string | number) {
|
||||
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
|
||||
}
|
||||
|
||||
function handleGrayscaleChange(value: boolean) {
|
||||
themeStore.setGrayscale(value);
|
||||
}
|
||||
|
||||
function handleColourWeaknessChange(value: boolean) {
|
||||
themeStore.setColourWeakness(value);
|
||||
}
|
||||
|
||||
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<div class="i-flex-center">
|
||||
<ElTabs v-model="themeStore.themeScheme" type="border-card" class="segment" @tab-change="handleSegmentChange">
|
||||
<ElTabPane v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
|
||||
<template #label>
|
||||
<SvgIcon :icon="icons[key]" class="h-23px text-icon-small" />
|
||||
</template>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
<Transition name="sider-inverted">
|
||||
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
|
||||
<ElSwitch v-model="themeStore.sider.inverted" />
|
||||
</SettingItem>
|
||||
</Transition>
|
||||
<SettingItem :label="$t('theme.grayscale')">
|
||||
<ElSwitch v-model:model-value="themeStore.grayscale" :update:model-value="handleGrayscaleChange" />
|
||||
</SettingItem>
|
||||
<SettingItem :label="$t('theme.colourWeakness')">
|
||||
<ElSwitch v-model:model-value="themeStore.colourWeakness" :update:model-value="handleColourWeaknessChange" />
|
||||
</SettingItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sider-inverted-enter-active,
|
||||
.sider-inverted-leave-active {
|
||||
--uno: h-22px transition-all-300;
|
||||
}
|
||||
|
||||
.sider-inverted-enter-from,
|
||||
.sider-inverted-leave-to {
|
||||
--uno: translate-x-20px opacity-0 h-0;
|
||||
}
|
||||
</style>
|
||||
79
src/layouts/modules/theme-drawer/modules/layout-mode.vue
Normal file
79
src/layouts/modules/theme-drawer/modules/layout-mode.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import LayoutModeCard from '../components/layout-mode-card.vue';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
|
||||
defineOptions({ name: 'LayoutMode' });
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
function handleReverseHorizontalMixChange(value: boolean | string | number) {
|
||||
themeStore.setLayoutReverseHorizontalMix(value as boolean);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDivider>{{ $t('theme.layoutMode.title') }}</ElDivider>
|
||||
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
|
||||
<template #vertical>
|
||||
<div class="layout-sider h-full w-18px"></div>
|
||||
<div class="vertical-wrapper">
|
||||
<div class="layout-header"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #vertical-mix>
|
||||
<div class="layout-sider h-full w-8px"></div>
|
||||
<div class="layout-sider h-full w-16px"></div>
|
||||
<div class="vertical-wrapper">
|
||||
<div class="layout-header"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #horizontal>
|
||||
<div class="layout-header"></div>
|
||||
<div class="horizontal-wrapper">
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #horizontal-mix>
|
||||
<div class="layout-header"></div>
|
||||
<div class="horizontal-wrapper">
|
||||
<div class="layout-sider w-18px"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutModeCard>
|
||||
<SettingItem
|
||||
v-if="themeStore.layout.mode === 'horizontal-mix'"
|
||||
:label="$t('theme.layoutMode.reverseHorizontalMix')"
|
||||
class="mt-16px"
|
||||
>
|
||||
<ElSwitch v-model="themeStore.layout.reverseHorizontalMix" @change="handleReverseHorizontalMixChange" />
|
||||
</SettingItem>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-header {
|
||||
--uno: h-16px bg-primary rd-4px;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
--uno: bg-primary-300 rd-4px;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
--uno: flex-1 bg-primary-200 rd-4px;
|
||||
}
|
||||
|
||||
.vertical-wrapper {
|
||||
--uno: flex-1 flex-col gap-6px;
|
||||
}
|
||||
|
||||
.horizontal-wrapper {
|
||||
--uno: flex-1 flex gap-6px;
|
||||
}
|
||||
</style>
|
||||
148
src/layouts/modules/theme-drawer/modules/page-fun.vue
Normal file
148
src/layouts/modules/theme-drawer/modules/page-fun.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { themePageAnimationModeOptions, themeScrollModeOptions, themeTabModeOptions } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
|
||||
defineOptions({ name: 'PageFun' });
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const layoutMode = computed(() => themeStore.layout.mode);
|
||||
|
||||
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix'));
|
||||
|
||||
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDivider>{{ $t('theme.pageFunTitle') }}</ElDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
|
||||
<ElSelect v-model="themeStore.layout.scrollMode" size="small" class="w-120px">
|
||||
<ElOption
|
||||
v-for="{ label, value } in translateOptions(themeScrollModeOptions)"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</SettingItem>
|
||||
<SettingItem key="1-1" :label="$t('theme.page.animate')">
|
||||
<ElSwitch v-model="themeStore.page.animate" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
|
||||
<ElSelect v-model="themeStore.page.animateMode" size="small" class="w-120px">
|
||||
<ElOption
|
||||
v-for="{ label, value } in translateOptions(themePageAnimationModeOptions)"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
|
||||
<ElSwitch v-model="themeStore.fixedHeaderAndTab" />
|
||||
</SettingItem>
|
||||
<SettingItem key="3" :label="$t('theme.header.height')">
|
||||
<ElInputNumber v-model="themeStore.header.height" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
|
||||
<ElSwitch v-model="themeStore.header.breadcrumb.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
|
||||
<ElSwitch v-model="themeStore.header.breadcrumb.showIcon" />
|
||||
</SettingItem>
|
||||
<SettingItem key="5" :label="$t('theme.tab.visible')">
|
||||
<ElSwitch v-model="themeStore.tab.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
|
||||
<ElSwitch v-model="themeStore.tab.cache" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
|
||||
<ElInputNumber v-model="themeStore.tab.height" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
|
||||
<ElSelect v-model="themeStore.tab.mode" size="small" class="w-120px">
|
||||
<ElOption
|
||||
v-for="{ label, value } in translateOptions(themeTabModeOptions)"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</SettingItem>
|
||||
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
|
||||
<ElInputNumber v-model="themeStore.sider.width" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
|
||||
<ElInputNumber v-model="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
|
||||
<ElInputNumber v-model="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
|
||||
<ElInputNumber v-model="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="layoutMode === 'vertical-mix'" key="6-5" :label="$t('theme.sider.mixChildMenuWidth')">
|
||||
<ElInputNumber v-model="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem key="7" :label="$t('theme.footer.visible')">
|
||||
<ElSwitch v-model="themeStore.footer.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
|
||||
<ElSwitch v-model="themeStore.footer.fixed" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
|
||||
<ElInputNumber v-model="themeStore.footer.height" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
v-if="themeStore.footer.visible && layoutMode === 'horizontal-mix'"
|
||||
key="7-3"
|
||||
:label="$t('theme.footer.right')"
|
||||
>
|
||||
<ElSwitch v-model="themeStore.footer.right" />
|
||||
</SettingItem>
|
||||
<SettingItem key="8" :label="$t('theme.watermark.visible')">
|
||||
<ElSwitch v-model="themeStore.watermark.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.watermark.visible" key="8-1" :label="$t('theme.watermark.enableUserName')">
|
||||
<ElSwitch v-model="themeStore.watermark.enableUserName" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.watermark.visible" key="8-2" :label="$t('theme.watermark.text')">
|
||||
<ElInput
|
||||
v-model="themeStore.watermark.text"
|
||||
autosize
|
||||
type="text"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
placeholder="CN-RDMS"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem key="9" :label="$t('theme.header.multilingual.visible')">
|
||||
<ElSwitch v-model="themeStore.header.multilingual.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem key="10" :label="$t('theme.header.globalSearch.visible')">
|
||||
<ElSwitch v-model="themeStore.header.globalSearch.visible" />
|
||||
</SettingItem>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-list-move,
|
||||
.setting-list-enter-active,
|
||||
.setting-list-leave-active {
|
||||
--uno: transition-all-300;
|
||||
}
|
||||
|
||||
.setting-list-enter-from,
|
||||
.setting-list-leave-to {
|
||||
--uno: opacity-0 -translate-x-30px;
|
||||
}
|
||||
|
||||
.setting-list-leave-active {
|
||||
--uno: absolute;
|
||||
}
|
||||
</style>
|
||||
78
src/layouts/modules/theme-drawer/modules/theme-color.vue
Normal file
78
src/layouts/modules/theme-drawer/modules/theme-color.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
|
||||
defineOptions({ name: 'ThemeColor' });
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
function handleUpdateColor(color: string | null, key: App.Theme.ThemeColorKey) {
|
||||
if (color !== null) {
|
||||
themeStore.updateThemeColors(key, color);
|
||||
}
|
||||
}
|
||||
|
||||
const swatches: string[] = [
|
||||
'#3b82f6',
|
||||
'#6366f1',
|
||||
'#8b5cf6',
|
||||
'#a855f7',
|
||||
'#0ea5e9',
|
||||
'#06b6d4',
|
||||
'#f43f5e',
|
||||
'#ef4444',
|
||||
'#ec4899',
|
||||
'#d946ef',
|
||||
'#f97316',
|
||||
'#f59e0b',
|
||||
'#eab308',
|
||||
'#84cc16',
|
||||
'#22c55e',
|
||||
'#10b981'
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDivider>{{ $t('theme.themeColor.title') }}</ElDivider>
|
||||
<div class="flex-col-stretch gap-12px">
|
||||
<ElTooltip placement="top-start">
|
||||
<template #content>
|
||||
<p>
|
||||
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span>
|
||||
<br />
|
||||
<ElButton
|
||||
text
|
||||
tag="a"
|
||||
href="https://uicolors.app/create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray"
|
||||
>
|
||||
https://uicolors.app/create
|
||||
</ElButton>
|
||||
</p>
|
||||
</template>
|
||||
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')">
|
||||
<ElSwitch v-model="themeStore.recommendColor" />
|
||||
</SettingItem>
|
||||
</ElTooltip>
|
||||
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
|
||||
<template v-if="key === 'info'" #suffix>
|
||||
<ElCheckbox v-model="themeStore.isInfoFollowPrimary">
|
||||
{{ $t('theme.themeColor.followPrimary') }}
|
||||
</ElCheckbox>
|
||||
</template>
|
||||
<ElColorPicker
|
||||
v-model="themeStore.themeColors[key]"
|
||||
class="w-40px"
|
||||
:disabled="key === 'info' && themeStore.isInfoFollowPrimary"
|
||||
:show-alpha="false"
|
||||
:predefine="swatches"
|
||||
@change="handleUpdateColor($event, key)"
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user