2
0

初始化项目

This commit is contained in:
caiyuchao
2024-11-14 11:06:38 +08:00
parent 988b9e6799
commit 4ffac789e1
320 changed files with 34244 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed } 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 layoutMode = computed(() => {
const vertical: LayoutMode = 'vertical';
const horizontal: LayoutMode = 'horizontal';
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
});
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: false
}
};
const headerProps = computed(() => headerPropsConfig[themeStore.layout.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 { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed) {
w += mixChildMenuWidth;
}
return w;
}
function getSiderCollapsedWidth() {
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed) {
w += mixChildMenuWidth;
}
return w;
}
setupMixMenuContext();
</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>
<GlobalContent />
<ThemeDrawer />
<template #footer>
<GlobalFooter />
</template>
</AdminLayout>
</template>
<style lang="scss">
#__SCROLL_EL_ID__ {
@include scrollbar();
}
</style>

View File

@@ -0,0 +1,13 @@
<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>

View File

@@ -0,0 +1,4 @@
import { useContext } from '@sa/hooks';
import { useMixMenu } from '../hooks';
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);

View File

@@ -0,0 +1,44 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRouteStore } from '@/store/modules/route';
export function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
const [firstLevelRouteName] = routeName.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const menus = computed(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey,
menus
};
}

View File

@@ -0,0 +1,47 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import { useRouteStore } from '@/store/modules/route';
export function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
const [firstLevelRouteName] = routeName.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const menus = computed(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey,
menus
};
}
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useAttrs } from 'vue';
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',
inheritAttrs: false
});
const attrs = useAttrs();
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>
<!-- 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 -->
<ABreadcrumb v-if="themeStore.header.breadcrumb.visible" v-bind="attrs">
<ABreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
<BreadcrumbContent :breadcrumb="item" />
<template v-if="item.children?.length" #overlay>
<AMenu>
<AMenuItem v-for="option in item.children" :key="option.key" @click="handleClickMenu(option.routeKey)">
<BreadcrumbContent :breadcrumb="option" />
</AMenuItem>
</AMenu>
</template>
</ABreadcrumbItem>
</ABreadcrumb>
</template>
<style scoped></style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
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();
</script>
<template>
<RouterView v-slot="{ Component, route }">
<Transition
:name="themeStore.page.animateMode"
mode="out-in"
@before-leave="appStore.setContentXScrollable(true)"
@after-enter="appStore.setContentXScrollable(false)"
>
<KeepAlive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="appStore.reloadFlag"
:key="route.path"
:class="{ 'p-16px': showPadding }"
class="flex-grow bg-layout transition-300"
/>
</KeepAlive>
</Transition>
</RouterView>
</template>
<style></style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
defineOptions({
name: 'GlobalFooter'
});
</script>
<template>
<DarkModeContainer class="h-full flex-center">
<a href="https://github.com/honghuangdc/soybean-admin/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
Copyright MIT © 2021 Soybean
</a>
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,21 @@
<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')"
trigger-parent
@click="appStore.openThemeDrawer"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { Modal } from 'ant-design-vue';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
defineOptions({
name: 'UserAvatar'
});
const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush();
function loginOrRegister() {
toLogin();
}
function logout() {
Modal.confirm({
title: $t('common.tip'),
content: $t('common.logoutConfirm'),
okText: $t('common.confirm'),
cancelText: $t('common.cancel'),
onOk: () => {
authStore.resetStore();
}
});
}
</script>
<template>
<AButton v-if="!authStore.isLogin" @click="loginOrRegister">{{ $t('page.login.common.loginOrRegister') }}</AButton>
<ADropdown v-else placement="bottomRight" trigger="click">
<ButtonIcon>
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.user?.nickName }}</span>
</ButtonIcon>
<template #overlay>
<AMenu>
<AMenuItem @click="routerPushByKey('user-center')">
<div class="flex-center gap-8px">
<SvgIcon icon="ph:user-circle" class="text-icon" />
{{ $t('common.userCenter') }}
</div>
</AMenuItem>
<AMenuDivider />
<AMenuItem @click="logout">
<div class="flex-center gap-8px">
<SvgIcon icon="ph:sign-out" class="text-icon" />
{{ $t('common.logout') }}
</div>
</AMenuItem>
</AMenu>
</template>
</ADropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useFullscreen } from '@vueuse/core';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import HorizontalMenu from '../global-menu/base-menu.vue';
import GlobalLogo from '../global-logo/index.vue';
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import { useMixMenuContext } from '../../context';
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 routeStore = useRouteStore();
const { isFullscreen, toggle } = useFullscreen();
const { menus } = useMixMenuContext();
const headerMenus = computed(() => {
if (themeStore.layout.mode === 'horizontal') {
return routeStore.menus;
}
if (themeStore.layout.mode === 'horizontal-mix') {
return menus.value;
}
return [];
});
</script>
<template>
<DarkModeContainer class="h-full flex-y-center shadow-header">
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: themeStore.sider.width + 'px' }" />
<HorizontalMenu v-if="showMenu" mode="horizontal" :menus="headerMenus" class="px-12px" />
<div v-else class="h-full flex-y-center flex-1-hidden">
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
</div>
<div class="h-full flex-y-center justify-end">
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
<LangSwitch :lang="appStore.locale" :lang-options="appStore.localeOptions" @change-lang="appStore.changeLocale" />
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:is-dark="themeStore.darkMode"
@switch="themeStore.toggleThemeScheme"
/>
<ThemeButton />
<UserAvatar />
</div>
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,27 @@
<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>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import type { MenuInfo, MenuMode } from 'ant-design-vue/es/menu/src/interface';
import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/utils';
import type { RouteKey } from '@elegant-router/types';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
defineOptions({
name: 'BaseMenu'
});
interface Props {
darkTheme?: boolean;
mode?: MenuMode;
menus: App.Global.Menu[];
}
const props = withDefaults(defineProps<Props>(), {
mode: 'inline'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKey } = useRouterPush();
const menuTheme = computed(() => (props.darkTheme ? 'dark' : 'light'));
const isHorizontal = computed(() => props.mode === 'horizontal');
const siderCollapse = computed(() => themeStore.layout.mode === 'vertical' && appStore.siderCollapse);
const inlineCollapsed = computed(() => (props.mode === 'inline' ? siderCollapse.value : undefined));
const selectedKeys = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return [routeName];
});
const openKeys = computed(() => {
if (isHorizontal.value || inlineCollapsed.value) return [];
const [selectedKey] = selectedKeys.value;
if (!selectedKey) return [];
return routeStore.getSelectedMenuKeyPath(selectedKey);
});
const headerHeight = computed(() => `${themeStore.header.height}px`);
const selectedBgColor = computed(() => {
const { darkMode, themeColor } = themeStore;
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff');
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000');
return darkMode ? dark : light;
});
function handleClickMenu(menuInfo: MenuInfo) {
const key = menuInfo.key as RouteKey;
const { query } = routeStore.getSelectedMenuMetaByKey(key) || {};
routerPushByKey(key, { query });
}
</script>
<template>
<SimpleScrollbar class="menu-wrapper" :class="{ 'select-menu': !darkTheme }">
<AMenu
:mode="mode"
:theme="menuTheme"
:items="menus"
:selected-keys="selectedKeys"
:open-keys="openKeys"
:inline-collapsed="inlineCollapsed"
:inline-indent="18"
class="size-full transition-300 border-0!"
:class="{ 'bg-container!': !darkTheme, 'horizontal-menu': isHorizontal }"
@click="handleClickMenu"
/>
</SimpleScrollbar>
</template>
<style lang="scss" scoped>
.menu-wrapper {
:deep(.ant-menu-inline) {
.ant-menu-item {
width: calc(100% - 16px);
margin-inline: 8px;
}
}
:deep(.ant-menu-submenu-title) {
width: calc(100% - 16px);
margin-inline: 8px;
}
:deep(.ant-menu-inline-collapsed) {
> .ant-menu-item {
padding-inline: calc(50% - 14px);
}
.ant-menu-item-icon {
vertical-align: -0.25em;
}
.ant-menu-submenu-title {
padding-inline: calc(50% - 14px);
}
}
:deep(.ant-menu-horizontal) {
.ant-menu-item {
display: flex;
align-items: center;
}
.ant-menu-submenu-title {
display: flex;
align-items: center;
}
}
}
.select-menu {
:deep(.ant-menu-inline) {
.ant-menu-item-selected {
background-color: v-bind(selectedBgColor);
}
}
}
.horizontal-menu {
line-height: v-bind(headerHeight);
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/utils';
import { useAppStore } from '@/store/modules/app';
import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '@/store/modules/theme';
defineOptions({
name: 'FirstLevelMenu'
});
interface Props {
activeMenuKey?: string;
inverted?: boolean;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
interface Emits {
(e: 'select', menu: App.Global.Menu): boolean;
}
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
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 } = themeStore;
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);
}
</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-24px pt-4px']"
>
{{ label }}
</p>
</div>
</DefineMixMenuItem>
<!-- template -->
<div class="h-full flex-col-stretch flex-1-hidden">
<slot></slot>
<SimpleScrollbar>
<MixMenuItem
v-for="menu in routeStore.menus"
:key="menu.key"
:label="menu.label"
:icon="menu.icon"
:active="menu.key === activeMenuKey"
:is-mini="appStore.siderCollapse"
@click="handleClickMixMenu(menu)"
/>
</SimpleScrollbar>
<MenuToggler
arrow-icon
:collapsed="appStore.siderCollapse"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleSiderCollapse"
/>
</div>
</template>
<style scoped>
.selected-mix-menu {
background-color: v-bind(selectedBgColor);
}
</style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useRouterPush } from '@/hooks/common/router';
import { useMixMenuContext } from '../../context';
import FirstLevelMenu from './first-level-menu.vue';
defineOptions({
name: 'HorizontalMixMenu'
});
const { activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
const { routerPushByKey } = useRouterPush();
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKey(menu.routeKey);
}
}
</script>
<template>
<FirstLevelMenu :active-menu-key="activeFirstLevelMenuKey" @select="handleSelectMixMenu">
<slot></slot>
</FirstLevelMenu>
</template>
<style scoped></style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useBoolean } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { useMixMenu } from '../../hooks';
import FirstLevelMenu from './first-level-menu.vue';
import BaseMenu from './base-menu.vue';
defineOptions({
name: 'VerticalMixMenu'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKey } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const { activeFirstLevelMenuKey, setActiveFirstLevelMenuKey, getActiveFirstLevelMenuKey } = useMixMenu();
const siderInverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const menus = computed(() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []);
const showDrawer = computed(() => (drawerVisible.value && menus.value.length) || appStore.mixSiderFixed);
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (menu.children?.length) {
setDrawerVisible(true);
} else {
routerPushByKey(menu.routeKey);
}
}
function handleResetActiveMenu() {
getActiveFirstLevelMenuKey();
setDrawerVisible(false);
}
</script>
<template>
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu :active-menu-key="activeFirstLevelMenuKey" :inverted="siderInverted" @select="handleSelectMixMenu">
<slot></slot>
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="siderInverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="pl-8px text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': siderInverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<BaseMenu :dark-theme="siderInverted" :menus="menus" />
</DarkModeContainer>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import GlobalLogo from '../global-logo/index.vue';
import VerticalMenu from '../global-menu/base-menu.vue';
import VerticalMixMenu from '../global-menu/vertical-mix-menu.vue';
import HorizontalMixMenu from '../global-menu/horizontal-mix-menu.vue';
defineOptions({
name: 'GlobalSider'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
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);
</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' }"
/>
<VerticalMixMenu v-if="isVerticalMix">
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</VerticalMixMenu>
<HorizontalMixMenu v-else-if="isHorizontalMix" />
<VerticalMenu v-else :dark-theme="darkMenu" :menus="routeStore.menus" />
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Trigger } from 'ant-design-vue/es/dropdown/props';
import { $t } from '@/locales';
import { useTabStore } from '@/store/modules/tab';
defineOptions({
name: 'ContextMenu'
});
interface Props {
tabId: string;
trigger?: Trigger[];
excludeKeys?: App.Global.DropdownKey[];
disabledKeys?: App.Global.DropdownKey[];
}
const props = withDefaults(defineProps<Props>(), {
trigger: () => ['contextmenu'],
excludeKeys: () => [],
disabledKeys: () => []
});
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs } = useTabStore();
interface DropdownOption {
key: App.Global.DropdownKey;
label: string;
icon: string;
disabled?: boolean;
}
const options = computed(() => {
const opts: DropdownOption[] = [
{
key: 'closeCurrent',
label: $t('dropdown.closeCurrent'),
icon: 'ant-design:close-outlined'
},
{
key: 'closeOther',
label: $t('dropdown.closeOther'),
icon: 'ant-design:column-width-outlined'
},
{
key: 'closeLeft',
label: $t('dropdown.closeLeft'),
icon: 'mdi:format-horizontal-align-left'
},
{
key: 'closeRight',
label: $t('dropdown.closeRight'),
icon: 'mdi:format-horizontal-align-right'
},
{
key: 'closeAll',
label: $t('dropdown.closeAll'),
icon: 'ant-design:line-outlined'
}
];
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 dropdownAction: Record<App.Global.DropdownKey, () => void> = {
closeCurrent() {
removeTab(props.tabId);
},
closeOther() {
clearTabs([props.tabId]);
},
closeLeft() {
clearLeftTabs(props.tabId);
},
closeRight() {
clearRightTabs(props.tabId);
},
closeAll() {
clearTabs();
}
};
</script>
<template>
<ADropdown :trigger="trigger" placement="bottom" destroy-popup-on-hide>
<slot></slot>
<template #overlay>
<AMenu>
<AMenuItem
v-for="option in options"
:key="option.key"
:disabled="option.disabled"
@click="dropdownAction[option.key]"
>
<div class="flex-y-center gap-12px">
<SvgIcon :icon="option.icon" class="text-icon" />
<span>{{ option.label }}</span>
</div>
</AMenuItem>
</AMenu>
</template>
</ADropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,157 @@
<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 BetterScroll from '@/components/custom/better-scroll.vue';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useTabStore } from '@/store/modules/tab';
import ContextMenu from './context-menu.vue';
defineOptions({
name: 'GlobalTab'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
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 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;
}
async function handleCloseTab(tab: App.Global.Tab) {
await tabStore.removeTab(tab.id);
await routeStore.reCacheRoutesByKey(tab.routeKey);
}
async function refresh() {
appStore.reloadPage(500);
}
function init() {
tabStore.initTabStore(route);
}
// 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: appStore.isMobile }">
<div
ref="tabRef"
class="h-full flex pr-18px"
:class="[themeStore.tab.mode === 'chrome' ? 'items-end' : 'items-center gap-12px']"
>
<ContextMenu
v-for="tab in tabStore.tabs"
:key="tab.id"
:tab-id="tab.id"
:disabled-keys="getContextMenuDisabledKeys(tab.id)"
>
<PageTab
:[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)"
>
<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>
</ContextMenu>
</div>
</BetterScroll>
</div>
<ReloadButton :loading="!appStore.reloadFlag" @click="refresh" />
<FullScreen :full="appStore.fullContent" @click="appStore.toggleFullContent" />
</DarkModeContainer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip';
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: TooltipPlacement;
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)"
>
<ATooltip :placement="item.placement" :title="$t(themeLayoutModeRecord[key])">
<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>
</ATooltip>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,24 @@
<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>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { SimpleScrollbar } from '@sa/materials';
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>
<ADrawer
:open="appStore.themeDrawerVisible"
:title="$t('theme.themeDrawerTitle')"
:closable="false"
:body-style="{ padding: '0px' }"
@close="appStore.closeThemeDrawer"
>
<template #extra>
<ButtonIcon icon="ant-design:close-outlined" class="h-28px" @click="appStore.closeThemeDrawer" />
</template>
<SimpleScrollbar>
<div class="px-24px pb-24px pt-8px">
<DarkMode />
<LayoutMode />
<ThemeColor />
<PageFun />
</div>
</SimpleScrollbar>
<template #footer>
<ConfigOperation />
</template>
</ADrawer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import Clipboard from 'clipboard';
import { $t } from '@/locales';
import { useThemeStore } from '@/store/modules/theme';
defineOptions({
name: 'ConfigOperation'
});
const themeStore = useThemeStore();
const domRef = ref<HTMLElement | null>(null);
function initClipboard() {
if (!domRef.value) return;
const clipboard = new Clipboard(domRef.value, {
text: () => getClipboardText()
});
clipboard.on('success', () => {
$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(() => {
$message?.success($t('theme.configOperation.resetSuccessMsg'));
}, 50);
}
onMounted(() => {
initClipboard();
});
</script>
<template>
<div class="flex justify-between">
<AButton danger @click="handleReset">{{ $t('theme.configOperation.resetConfig') }}</AButton>
<div ref="domRef">
<AButton type="primary">{{ $t('theme.configOperation.copyConfig') }}</AButton>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { SegmentedOption } from 'ant-design-vue/es/segmented/src/segmented';
import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
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 getSegmentOptions() {
const opts: SegmentedOption[] = Object.keys(themeSchemaRecord).map(item => {
const key = item as UnionKey.ThemeScheme;
return {
value: item,
payload: {
icon: icons[key]
}
};
});
return opts;
}
const options = computed(() => getSegmentOptions());
function handleSegmentChange(value: string | number) {
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
}
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
</script>
<template>
<ADivider>{{ $t('theme.themeSchema.title') }}</ADivider>
<div class="flex-col-stretch gap-16px">
<div class="i-flex-center">
<ASegmented :value="themeStore.themeScheme" :options="options" class="bg-layout" @change="handleSegmentChange">
<template #label="{ payload }">
<ButtonIcon :icon="payload.icon" class="h-28px text-icon-small" />
</template>
</ASegmented>
</div>
<Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
<ASwitch v-model:checked="themeStore.sider.inverted" />
</SettingItem>
</Transition>
</div>
</template>
<style 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>

View File

@@ -0,0 +1,69 @@
<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';
defineOptions({
name: 'LayoutMode'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
</script>
<template>
<ADivider>{{ $t('theme.layoutMode.title') }}</ADivider>
<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>
</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>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
import { useThemeStore } from '@/store/modules/theme';
import { themePageAnimationModeOptions, themeScrollModeOptions, themeTabModeOptions } from '@/constants/app';
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>
<ADivider>{{ $t('theme.pageFunTitle') }}</ADivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
<ASelect v-model:value="themeStore.layout.scrollMode" class="w-120px">
<ASelectOption v-for="option in themeScrollModeOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</SettingItem>
<SettingItem key="1-1" :label="$t('theme.page.animate')">
<ASwitch v-model:checked="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
<ASelect v-model:value="themeStore.page.animateMode" class="w-120px">
<ASelectOption v-for="option in themePageAnimationModeOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
<ASwitch v-model:checked="themeStore.fixedHeaderAndTab" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.header.height')">
<AInputNumber v-model:value="themeStore.header.height" class="w-120px" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
<ASwitch v-model:checked="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<ASwitch v-model:checked="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<ASwitch v-model:checked="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<ASwitch v-model:checked="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<AInputNumber v-model:value="themeStore.tab.height" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
<ASelect v-model:value="themeStore.tab.mode" class="w-120px">
<ASelectOption v-for="option in themeTabModeOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<AInputNumber v-model:value="themeStore.sider.width" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
<AInputNumber v-model:value="themeStore.sider.collapsedWidth" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
<AInputNumber v-model:value="themeStore.sider.mixWidth" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
<AInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="6-5" :label="$t('theme.sider.mixChildMenuWidth')">
<AInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" class="w-120px" />
</SettingItem>
<SettingItem key="7" :label="$t('theme.footer.visible')">
<ASwitch v-model:checked="themeStore.footer.visible" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
<ASwitch v-model:checked="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
<AInputNumber v-model:value="themeStore.footer.height" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && layoutMode === 'horizontal-mix'"
key="7-3"
:label="$t('theme.footer.right')"
>
<ASwitch v-model:checked="themeStore.footer.right" />
</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>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ColorPicker } from '@sa/materials';
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, key: App.Theme.ThemeColorKey) {
themeStore.updateThemeColors(key, color);
}
</script>
<template>
<ADivider>{{ $t('theme.themeColor.title') }}</ADivider>
<div class="flex-col-stretch gap-12px">
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
<template v-if="key === 'info'" #suffix>
<ACheckbox v-model:checked="themeStore.isInfoFollowPrimary">
{{ $t('theme.themeColor.followPrimary') }}
</ACheckbox>
</template>
<ColorPicker
:color="themeStore.themeColors[key]"
:disabled="key === 'info' && themeStore.isInfoFollowPrimary"
@update:color="handleUpdateColor($event, key)"
/>
</SettingItem>
</div>
</template>
<style scoped></style>