feat(projects): 1.0 beta

This commit is contained in:
Soybean
2023-11-17 08:45:00 +08:00
parent 1ea4817f6a
commit e918a2c0f5
499 changed files with 15918 additions and 24708 deletions

View File

@@ -0,0 +1,131 @@
<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 '../hooks/use-mix-menu';
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"
: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

@@ -1,62 +0,0 @@
<template>
<admin-layout
:mode="mode"
:is-mobile="isMobile"
:scroll-mode="theme.scrollMode"
:scroll-el-id="app.scrollElId"
:full-content="app.contentFull"
:fixed-top="theme.fixedHeaderAndTab"
:header-height="theme.header.height"
:tab-visible="theme.tab.visible"
:tab-height="theme.tab.height"
:content-class="app.disableMainXScroll ? 'overflow-x-hidden' : ''"
:sider-visible="siderVisible"
:sider-collapse="app.siderCollapse"
:sider-width="siderWidth"
:sider-collapsed-width="siderCollapsedWidth"
:footer-visible="theme.footer.visible"
:fixed-footer="theme.footer.fixed"
:right-footer="theme.footer.right"
@click-mobile-sider-mask="app.setSiderCollapse(true)"
>
<template #header>
<global-header v-bind="headerProps" />
</template>
<template #tab>
<global-tab />
</template>
<template #sider>
<global-sider />
</template>
<global-content />
<template #footer>
<global-footer />
</template>
</admin-layout>
<n-back-top :key="theme.scrollMode" :listen-to="`#${app.scrollElId}`" class="z-100" />
<setting-drawer />
</template>
<script setup lang="ts">
import { AdminLayout } from '@soybeanjs/vue-materials';
import { useAppStore, useThemeStore } from '@/store';
import { useBasicLayout } from '@/composables';
import { GlobalContent, GlobalFooter, GlobalHeader, GlobalSider, GlobalTab, SettingDrawer } from '../common';
defineOptions({ name: 'BasicLayout' });
const app = useAppStore();
const theme = useThemeStore();
const { mode, isMobile, headerProps, siderVisible, siderWidth, siderCollapsedWidth } = useBasicLayout();
</script>
<style lang="scss">
#__SCROLL_EL_ID__ {
@include scrollbar(8px, #e1e1e1);
}
.dark #__SCROLL_EL_ID__ {
@include scrollbar(8px, #555);
}
</style>

View File

@@ -1,11 +1,13 @@
<template>
<global-content :show-padding="false" />
</template>
<script setup lang="ts">
import { GlobalContent } from '../common';
import GlobalContent from '../modules/global-content/index.vue';
defineOptions({ name: 'BlankLayout' });
defineOptions({
name: 'BlankLayout'
});
</script>
<template>
<GlobalContent :show-padding="false" />
</template>
<style scoped></style>

View File

@@ -1,42 +0,0 @@
<template>
<router-view v-slot="{ Component, route }">
<transition
:name="theme.pageAnimateMode"
mode="out-in"
:appear="true"
@before-leave="app.setDisableMainXScroll(true)"
@after-enter="app.setDisableMainXScroll(false)"
>
<keep-alive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="app.reloadFlag"
:key="route.fullPath"
:class="{ 'p-16px': showPadding }"
class="flex-grow bg-#f6f9f8 dark:bg-#101014 transition duration-300 ease-in-out"
/>
</keep-alive>
</transition>
</router-view>
</template>
<script setup lang="ts">
import { useAppStore, useRouteStore, useThemeStore } from '@/store';
defineOptions({ name: 'GlobalContent' });
interface Props {
/** 显示padding */
showPadding?: boolean;
}
withDefaults(defineProps<Props>(), {
showPadding: true
});
const app = useAppStore();
const theme = useThemeStore();
const routeStore = useRouteStore();
</script>
<style scoped></style>

View File

@@ -1,15 +0,0 @@
<template>
<dark-mode-container class="flex-center h-full" :inverted="theme.footer.inverted">
<span>Copyright ©2021 Soybean Admin</span>
</dark-mode-container>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/store';
defineOptions({ name: 'GlobalFooter' });
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,18 +0,0 @@
<template>
<hover-container class="w-40px h-full" tooltip-content="全屏" :inverted="theme.header.inverted" @click="toggle">
<icon-gridicons-fullscreen-exit v-if="isFullscreen" class="text-18px" />
<icon-gridicons-fullscreen v-else class="text-18px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useFullscreen } from '@vueuse/core';
import { useThemeStore } from '@/store';
defineOptions({ name: 'FullScreen' });
const { isFullscreen, toggle } = useFullscreen();
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,23 +0,0 @@
<template>
<hover-container
tooltip-content="github"
class="w-40px h-full"
:inverted="theme.header.inverted"
@click="handleClickLink"
>
<icon-mdi-github class="text-20px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
defineOptions({ name: 'GithubSite' });
const theme = useThemeStore();
function handleClickLink() {
window.open('https://github.com/honghuangdc/soybean-admin', '_blank');
}
</script>
<style scoped></style>

View File

@@ -1,62 +0,0 @@
<template>
<n-breadcrumb class="px-12px">
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.key">
<n-breadcrumb-item>
<n-dropdown v-if="breadcrumb.hasChildren" :options="breadcrumb.options" @select="dropdownSelect">
<span>
<component
:is="breadcrumb.icon"
v-if="theme.header.crumb.showIcon"
class="inline-block align-text-bottom mr-4px text-16px"
/>
<span>{{ breadcrumb.label }}</span>
</span>
</n-dropdown>
<template v-else>
<component
:is="breadcrumb.icon"
v-if="theme.header.crumb.showIcon"
class="inline-block align-text-bottom mr-4px text-16px"
:class="{ 'text-#BBBBBB': theme.header.inverted }"
/>
<span :class="{ 'text-#BBBBBB': theme.header.inverted }">
{{ breadcrumb.label }}
</span>
</template>
</n-breadcrumb-item>
</template>
</n-breadcrumb>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { routePath } from '@/router';
import { useRouteStore, useThemeStore } from '@/store';
import { useRouterPush } from '@/composables';
import { getBreadcrumbByRouteKey } from '@/utils';
import { $t } from '@/locales';
defineOptions({ name: 'GlobalBreadcrumb' });
const route = useRoute();
const theme = useThemeStore();
const routeStore = useRouteStore();
const { routerPush } = useRouterPush();
const breadcrumbs = computed(() =>
getBreadcrumbByRouteKey(route.name as string, routeStore.menus as App.GlobalMenuOption[], routePath('root')).map(
item => ({
...item,
label: item.i18nTitle ? $t(item.i18nTitle) : item.label,
options: item.options?.map(oItem => ({ ...oItem, label: oItem.i18nTitle ? $t(oItem.i18nTitle) : oItem.label }))
})
)
);
function dropdownSelect(key: string) {
routerPush({ name: key });
}
</script>
<style scoped></style>

View File

@@ -1,45 +0,0 @@
<template>
<div class="flex-1-hidden h-full px-10px">
<n-scrollbar :x-scrollable="true" class="flex-1-hidden h-full" content-class="h-full">
<div class="flex-y-center h-full" :style="{ justifyContent: theme.menu.horizontalPosition }">
<n-menu
:value="activeKey"
mode="horizontal"
:options="menus"
:inverted="theme.header.inverted"
@update:value="handleUpdateMenu"
/>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import type { MenuOption } from 'naive-ui';
import { useRouteStore, useThemeStore } from '@/store';
import { useRouterPush } from '@/composables';
import { translateMenuLabel } from '@/utils';
defineOptions({ name: 'HeaderMenu' });
const route = useRoute();
const routeStore = useRouteStore();
const theme = useThemeStore();
const { routerPush } = useRouterPush();
const menus = computed(() => translateMenuLabel(routeStore.menus as App.GlobalMenuOption[]));
const activeKey = computed(() => (route.meta?.activeMenu ? route.meta.activeMenu : route.name) as string);
function handleUpdateMenu(_key: string, item: MenuOption) {
const menuItem = item as App.GlobalMenuOption;
routerPush(menuItem.routePath);
}
</script>
<style scoped>
:deep(.n-menu-item-content-header) {
overflow: inherit !important;
}
</style>

View File

@@ -1,23 +0,0 @@
import MenuCollapse from './menu-collapse.vue';
import GlobalBreadcrumb from './global-breadcrumb.vue';
import HeaderMenu from './header-menu.vue';
import GithubSite from './github-site.vue';
import FullScreen from './full-screen.vue';
import ThemeMode from './theme-mode.vue';
import UserAvatar from './user-avatar.vue';
import SystemMessage from './system-message.vue';
import SettingButton from './setting-button.vue';
import ToggleLang from './toggle-lang.vue';
export {
MenuCollapse,
GlobalBreadcrumb,
HeaderMenu,
GithubSite,
FullScreen,
ThemeMode,
UserAvatar,
SystemMessage,
SettingButton,
ToggleLang
};

View File

@@ -1,17 +0,0 @@
<template>
<hover-container class="w-40px h-full" :inverted="theme.header.inverted" @click="app.toggleSiderCollapse">
<icon-line-md-menu-unfold-left v-if="app.siderCollapse" class="text-16px" />
<icon-line-md-menu-fold-left v-else class="text-16px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useAppStore, useThemeStore } from '@/store';
defineOptions({ name: 'MenuCollapse' });
const app = useAppStore();
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,57 +0,0 @@
<template>
<n-scrollbar class="max-h-360px">
<n-list>
<n-list-item
v-for="(item, index) in list"
:key="item.id"
class="hover:bg-#f6f6f6 dark:hover:bg-dark cursor-pointer"
@click="handleRead(index)"
>
<n-thing class="px-15px" :class="{ 'opacity-30': item.isRead }">
<template #avatar>
<n-avatar v-if="item.avatar" :src="item.avatar" />
<svg-icon v-else class="text-34px text-primary" :icon="item.icon" :local-icon="item.svgIcon" />
</template>
<template #header>
<n-ellipsis :line-clamp="1">
{{ item.title }}
<template #tooltip>
{{ item.title }}
</template>
</n-ellipsis>
</template>
<template v-if="item.tagTitle" #header-extra>
<n-tag v-bind="item.tagProps" size="small">{{ item.tagTitle }}</n-tag>
</template>
<template #description>
<n-ellipsis v-if="item.description" :line-clamp="2">
{{ item.description }}
</n-ellipsis>
<p>{{ item.date }}</p>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-scrollbar>
</template>
<script lang="ts" setup>
defineOptions({ name: 'MessageList' });
interface Props {
list?: App.MessageList[];
}
withDefaults(defineProps<Props>(), {
list: () => []
});
interface Emits {
(e: 'read', val: number): void;
}
const emit = defineEmits<Emits>();
function handleRead(index: number) {
emit('read', index);
}
</script>

View File

@@ -1,21 +0,0 @@
<template>
<hover-container
class="w-40px h-full"
tooltip-content="主题配置"
:inverted="theme.header.inverted"
@click="app.toggleSettingDrawerVisible"
>
<icon-ant-design-setting-outlined class="text-20px" />
</hover-container>
</template>
<script setup lang="ts">
import { useAppStore, useThemeStore } from '@/store';
defineOptions({ name: 'SettingButton' });
const app = useAppStore();
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,217 +0,0 @@
<template>
<n-popover class="!p-0" trigger="click" placement="bottom">
<template #trigger>
<hover-container tooltip-content="消息通知" :inverted="theme.header.inverted" class="relative w-40px h-full">
<icon-clarity:notification-line class="text-18px" />
<n-badge
:value="count"
:max="99"
:class="[count < 10 ? '-right-2px' : '-right-10px']"
class="absolute top-10px"
/>
</hover-container>
</template>
<n-tabs
v-model:value="currentTab"
:class="[isMobile ? 'w-276px' : 'w-360px']"
type="line"
justify-content="space-evenly"
>
<n-tab-pane v-for="(item, index) in tabData" :key="item.key" :name="index">
<template #tab>
<div class="flex-x-center items-center" :class="[isMobile ? 'w-92px' : 'w-120px']">
<span class="mr-5px">{{ item.name }}</span>
<n-badge
v-bind="item.badgeProps"
:value="item.list.filter(message => !message.isRead).length"
:max="99"
show-zero
/>
</div>
</template>
<loading-empty-wrapper
class="h-360px"
:loading="loading"
:empty="item.list.length === 0"
placeholder-class="bg-$n-color transition-background-color duration-300 ease-in-out"
>
<message-list :list="item.list" @read="handleRead" />
</loading-empty-wrapper>
</n-tab-pane>
</n-tabs>
<div v-if="showAction" class="flex border-t border-$n-divider-color cursor-pointer">
<div class="flex-1 text-center py-10px" @click="handleClear">清空</div>
<div class="flex-1 text-center py-10px border-l border-$n-divider-color" @click="handleAllRead">全部已读</div>
<div class="flex-1 text-center py-10px border-l border-$n-divider-color" @click="handleLoadMore">查看更多</div>
</div>
</n-popover>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useThemeStore } from '@/store';
import { useBasicLayout } from '@/composables';
import { useBoolean } from '@/hooks';
import MessageList from './message-list.vue';
defineOptions({ name: 'SystemMessage' });
const theme = useThemeStore();
const { isMobile } = useBasicLayout();
const { bool: loading, setBool: setLoading } = useBoolean();
const currentTab = ref(0);
const tabData = ref<App.MessageTab[]>([
{
key: 1,
name: '通知',
badgeProps: { type: 'warning' },
list: [
{ id: 1, icon: 'ri:message-3-line', title: '你收到了5条新消息', date: '2022-06-17' },
{ id: 4, icon: 'ri:message-3-line', title: 'Soybean Admin 1.0.0 版本正在筹备中', date: '2022-06-17' },
{ id: 2, icon: 'ri:message-3-line', title: 'Soybean Admin 0.9.6 版本发布了', date: '2022-06-16' },
{ id: 3, icon: 'ri:message-3-line', title: 'Soybean Admin 0.9.5 版本发布了', date: '2022-06-07' },
{
id: 5,
icon: 'ri:message-3-line',
title: '测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题',
date: '2022-06-17'
}
]
},
{
key: 2,
name: '消息',
badgeProps: { type: 'error' },
list: [
{
id: 1,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 刚才把工作台页面随便写了一些,凑合能看了!',
date: '2021-11-07 22:45:32'
},
{
id: 2,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 正在忙于为soybean-admin写项目说明文档',
date: '2021-11-03 20:33:31'
},
{
id: 3,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 准备为soybean-admin 1.0的发布做充分的准备工作!',
date: '2021-10-31 22:43:12'
},
{
id: 4,
title: '项目动态',
svgIcon: 'avatar',
description: '@yanbowe 向soybean-admin提交了一个bug多标签栏不会自适应。',
date: '2021-10-27 10:24:54'
},
{
id: 5,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 在2021年5月28日创建了开源项目soybean-admin',
date: '2021-05-28 22:22:22'
}
]
},
{
key: 3,
name: '待办',
badgeProps: { type: 'info' },
list: [
{
id: 1,
icon: 'ri:calendar-todo-line',
title: '缓存主题配置',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 2,
icon: 'ri:calendar-todo-line',
title: '添加锁屏组件、全局Iframe组件',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 3,
icon: 'ri:calendar-todo-line',
title: '示例页面完善',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 4,
icon: 'ri:calendar-todo-line',
title: '表单、表格示例',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 5,
icon: 'ri:calendar-todo-line',
title: '性能优化(优化递归函数)',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 6,
icon: 'ri:calendar-todo-line',
title: '精简版(新分支thin)',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
}
]
}
]);
const count = computed(() => {
return tabData.value.reduce((acc, cur) => {
return acc + cur.list.filter(item => !item.isRead).length;
}, 0);
});
const showAction = computed(() => tabData.value[currentTab.value].list.length > 0);
function handleRead(index: number) {
tabData.value[currentTab.value].list[index].isRead = true;
}
function handleAllRead() {
tabData.value[currentTab.value].list.forEach(item => Object.assign(item, { isRead: true }));
}
function handleClear() {
tabData.value[currentTab.value].list = [];
}
function handleLoadMore() {
const { list } = tabData.value[currentTab.value];
setLoading(true);
setTimeout(() => {
list.push(...tabData.value[currentTab.value].list);
setLoading(false);
}, 1000);
}
</script>
<style scoped></style>

View File

@@ -1,20 +0,0 @@
<template>
<hover-container class="w-40px" :inverted="theme.header.inverted" tooltip-content="主题模式">
<dark-mode-switch
:dark="theme.darkMode"
:customize-transition="theme.isCustomizeDarkModeTransition"
class="wh-full"
@update:dark="theme.setDarkMode"
/>
</hover-container>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
defineOptions({ name: 'ThemeMode' });
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,39 +0,0 @@
<template>
<hover-container class="w-40px h-full" :inverted="theme.header.inverted">
<n-dropdown :options="options" trigger="hover" :value="language" @select="handleSelect">
<icon-cil:language class="text-18px outline-transparent" />
</n-dropdown>
</hover-container>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useThemeStore } from '@/store';
import { localStg } from '@/utils';
const theme = useThemeStore();
const { locale } = useI18n();
const language = ref<I18nType.LangType>(localStg.get('lang') || 'zh-CN');
const options = [
{
label: '中文',
key: 'zh-CN'
},
{
label: 'English',
key: 'en'
},
{
label: 'ភាសាខ្មែរ',
key: 'km-KH'
}
];
const handleSelect = (key: string) => {
language.value = key as I18nType.LangType;
locale.value = key;
localStg.set('lang', key as I18nType.LangType);
};
</script>
<style scoped></style>

View File

@@ -1,56 +0,0 @@
<template>
<n-dropdown :options="options" @select="handleDropdown">
<hover-container class="px-12px" :inverted="theme.header.inverted">
<icon-local-avatar class="text-32px" />
<span class="pl-8px text-16px font-medium">{{ auth.userInfo.userName }}</span>
</hover-container>
</n-dropdown>
</template>
<script lang="ts" setup>
import type { DropdownOption } from 'naive-ui';
import { useAuthStore, useThemeStore } from '@/store';
import { useIconRender } from '@/composables';
defineOptions({ name: 'UserAvatar' });
const auth = useAuthStore();
const theme = useThemeStore();
const { iconRender } = useIconRender();
const options: DropdownOption[] = [
{
label: '用户中心',
key: 'user-center',
icon: iconRender({ icon: 'carbon:user-avatar' })
},
{
type: 'divider',
key: 'divider'
},
{
label: '退出登录',
key: 'logout',
icon: iconRender({ icon: 'carbon:logout' })
}
];
type DropdownKey = 'user-center' | 'logout';
function handleDropdown(optionKey: string) {
const key = optionKey as DropdownKey;
if (key === 'logout') {
window.$dialog?.info({
title: '提示',
content: '您确定要退出登录吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
auth.resetAuthStore();
}
});
}
}
</script>
<style scoped></style>

View File

@@ -1,63 +0,0 @@
<template>
<dark-mode-container class="global-header flex-y-center h-full" :inverted="theme.header.inverted">
<global-logo v-if="showLogo" :show-title="true" class="h-full" :style="{ width: theme.sider.width + 'px' }" />
<div v-if="!showHeaderMenu" class="flex-1-hidden flex-y-center h-full">
<menu-collapse v-if="showMenuCollapse || isMobile" />
<global-breadcrumb v-if="theme.header.crumb.visible && !isMobile" />
</div>
<header-menu v-else />
<div class="flex justify-end h-full">
<global-search />
<github-site />
<full-screen />
<theme-mode />
<toggle-lang />
<system-message />
<setting-button v-if="showButton" />
<user-avatar />
</div>
</dark-mode-container>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/store';
import { useBasicLayout } from '@/composables';
import GlobalLogo from '../global-logo/index.vue';
import GlobalSearch from '../global-search/index.vue';
import {
FullScreen,
GithubSite,
GlobalBreadcrumb,
HeaderMenu,
MenuCollapse,
SettingButton,
SystemMessage,
ThemeMode,
UserAvatar,
ToggleLang
} from './components';
defineOptions({ name: 'GlobalHeader' });
interface Props {
/** 显示logo */
showLogo: App.GlobalHeaderProps['showLogo'];
/** 显示头部菜单 */
showHeaderMenu: App.GlobalHeaderProps['showHeaderMenu'];
/** 显示菜单折叠按钮 */
showMenuCollapse: App.GlobalHeaderProps['showMenuCollapse'];
}
defineProps<Props>();
const theme = useThemeStore();
const { isMobile } = useBasicLayout();
const showButton = import.meta.env.PROD && import.meta.env.VITE_VERCEL !== 'Y';
</script>
<style scoped>
.global-header {
box-shadow: 0 1px 2px rgb(0 21 41 / 8%);
}
</style>

View File

@@ -1,26 +0,0 @@
<template>
<router-link :to="routeHomePath" class="flex-center w-full nowrap-hidden">
<system-logo class="text-32px text-primary" />
<h2 v-show="showTitle" class="pl-8px text-16px font-bold text-primary transition duration-300 ease-in-out">
{{ $t('system.title') }}
</h2>
</router-link>
</template>
<script setup lang="ts">
import { routePath } from '@/router';
import { $t } from '@/locales';
defineOptions({ name: 'GlobalLogo' });
interface Props {
/** 显示名字 */
showTitle: boolean;
}
defineProps<Props>();
const routeHomePath = routePath('root');
</script>
<style scoped></style>

View File

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

View File

@@ -1,30 +0,0 @@
<template>
<div class="px-24px h-44px flex-y-center">
<span class="mr-14px flex-y-center">
<icon-mdi-keyboard-return class="icon text-20px p-2px mr-6px" />
<span>确认</span>
</span>
<span class="mr-14px flex-y-center">
<icon-mdi-arrow-up-thin class="icon text-20px p-2px mr-5px" />
<icon-mdi-arrow-down-thin class="icon text-20px p-2px mr-6px" />
<span>切换</span>
</span>
<span class="flex-y-center">
<icon-mdi-keyboard-esc class="icon text-20px p-2px mr-6px" />
<span>关闭</span>
</span>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'SearchFooter' });
</script>
<style lang="scss" scoped>
.icon {
box-shadow:
inset 0 -2px #cdcde6,
inset 0 0 1px 1px #fff,
0 1px 2px 1px #1e235a66;
}
</style>

View File

@@ -1,146 +0,0 @@
<template>
<n-modal
v-model:show="show"
:segmented="{ footer: 'soft' }"
:closable="false"
preset="card"
footer-style="padding: 0; margin: 0"
class="fixed left-0 right-0"
:class="[isMobile ? 'wh-full top-0px rounded-0' : 'w-630px top-50px']"
@after-leave="handleClose"
>
<n-input-group>
<n-input ref="inputRef" v-model:value="keyword" clearable placeholder="请输入关键词搜索" @input="handleSearch">
<template #prefix>
<icon-uil-search class="text-15px text-#c2c2c2" />
</template>
</n-input>
<n-button v-if="isMobile" type="primary" ghost @click="handleClose">取消</n-button>
</n-input-group>
<div class="mt-20px">
<n-empty v-if="resultOptions.length === 0" description="暂无搜索结果" />
<search-result v-else v-model:value="activePath" :options="resultOptions" @enter="handleEnter" />
</div>
<template #footer>
<search-footer v-if="!isMobile" />
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, shallowRef, watch } from 'vue';
import { useRouter } from 'vue-router';
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
import { useRouteStore } from '@/store';
import { useBasicLayout } from '@/composables';
import { $t } from '@/locales';
import SearchResult from './search-result.vue';
import SearchFooter from './search-footer.vue';
defineOptions({ name: 'SearchModal' });
interface Props {
/** 弹窗显隐 */
value: boolean;
}
const props = defineProps<Props>();
interface Emits {
(e: 'update:value', val: boolean): void;
}
const emit = defineEmits<Emits>();
const { isMobile } = useBasicLayout();
const router = useRouter();
const routeStore = useRouteStore();
const keyword = ref('');
const activePath = ref('');
const resultOptions = shallowRef<AuthRoute.Route[]>([]);
const inputRef = ref<HTMLInputElement>();
const handleSearch = useDebounceFn(search, 300);
const show = computed({
get() {
return props.value;
},
set(val: boolean) {
emit('update:value', val);
}
});
watch(show, async val => {
if (val) {
/** 自动聚焦 */
await nextTick();
inputRef.value?.focus();
}
});
/** 查询 */
function search() {
resultOptions.value = routeStore.searchMenus.filter(menu => {
const trimKeyword = keyword.value.toLocaleLowerCase().trim();
const title = (menu.meta.i18nTitle ? $t(menu.meta.i18nTitle) : menu.meta.title).toLocaleLowerCase();
return trimKeyword && title.includes(trimKeyword);
});
activePath.value = resultOptions.value[0]?.path ?? '';
}
function handleClose() {
show.value = false;
/** 延时处理防止用户看到某些操作 */
setTimeout(() => {
resultOptions.value = [];
keyword.value = '';
}, 200);
}
/** key up */
function handleUp() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(item => item.path === activePath.value);
if (index === 0) {
activePath.value = resultOptions.value[length - 1].path;
} else {
activePath.value = resultOptions.value[index - 1].path;
}
}
/** key down */
function handleDown() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(item => item.path === activePath.value);
if (index + 1 === length) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = resultOptions.value[index + 1].path;
}
}
/** key enter */
function handleEnter() {
const { length } = resultOptions.value;
if (length === 0 || activePath.value === '') return;
const routeItem = resultOptions.value.find(item => item.path === activePath.value);
if (routeItem?.meta?.href) {
window.open(activePath.value, '__blank');
} else {
router.push(activePath.value);
handleClose();
}
}
onKeyStroke('Escape', handleClose);
onKeyStroke('Enter', handleEnter);
onKeyStroke('ArrowUp', handleUp);
onKeyStroke('ArrowDown', handleDown);
</script>
<style lang="scss" scoped></style>

View File

@@ -1,67 +0,0 @@
<template>
<n-scrollbar>
<div class="pb-12px">
<template v-for="item in options" :key="item.path">
<div
class="bg-#e5e7eb dark:bg-dark h-56px mt-8px px-14px rounded-4px cursor-pointer flex-y-center justify-between"
:style="{
background: item.path === active ? theme.themeColor : '',
color: item.path === active ? '#fff' : ''
}"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<svg-icon :icon="item.meta.icon" :local-icon="item.meta.localIcon" />
<span class="flex-1 ml-5px">
{{ (item.meta?.i18nTitle && $t(item.meta?.i18nTitle)) || item.meta?.title }}
</span>
<icon-ant-design-enter-outlined class="icon text-20px p-2px mr-3px" />
</div>
</template>
</div>
</n-scrollbar>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useThemeStore } from '@/store';
import { $t } from '@/locales';
defineOptions({ name: 'SearchResult' });
interface Props {
value: string;
options: AuthRoute.Route[];
}
const props = defineProps<Props>();
interface Emits {
(e: 'update:value', val: string): void;
(e: 'enter'): void;
}
const emit = defineEmits<Emits>();
const theme = useThemeStore();
const active = computed({
get() {
return props.value;
},
set(val: string) {
emit('update:value', val);
}
});
/** 鼠标移入 */
async function handleMouse(item: AuthRoute.Route) {
active.value = item.path;
}
function handleTo() {
emit('enter');
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,30 +0,0 @@
<template>
<div>
<hover-container
class="w-40px h-full"
tooltip-content="搜索"
:inverted="theme.header.inverted"
@click="handleSearch"
>
<icon-uil-search class="text-20px" />
</hover-container>
<search-modal v-model:value="show" />
</div>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
import { useBoolean } from '@/hooks';
import { SearchModal } from './components';
defineOptions({ name: 'GlobalSearch' });
const { bool: show, toggle } = useBoolean();
const theme = useThemeStore();
function handleSearch() {
toggle();
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,4 +0,0 @@
import VerticalSider from './vertical-sider/index.vue';
import VerticalMixSider from './vertical-mix-sider/index.vue';
export { VerticalSider, VerticalMixSider };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,57 +0,0 @@
<template>
<n-scrollbar class="flex-1-hidden">
<n-menu
:value="activeKey"
:collapsed="app.siderCollapse"
:collapsed-width="theme.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="menus"
:expanded-keys="expandedKeys"
:indent="18"
:inverted="!theme.darkMode && theme.sider.inverted"
@update:value="handleUpdateMenu"
@update:expanded-keys="handleUpdateExpandedKeys"
/>
</n-scrollbar>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { MenuOption } from 'naive-ui';
import { useAppStore, useRouteStore, useThemeStore } from '@/store';
import { useRouterPush } from '@/composables';
import { getActiveKeyPathsOfMenus, translateMenuLabel } from '@/utils';
defineOptions({ name: 'VerticalMenu' });
const route = useRoute();
const app = useAppStore();
const theme = useThemeStore();
const routeStore = useRouteStore();
const { routerPush } = useRouterPush();
const menus = computed(() => translateMenuLabel(routeStore.menus as App.GlobalMenuOption[]));
const activeKey = computed(() => (route.meta?.activeMenu ? route.meta.activeMenu : route.name) as string);
const expandedKeys = ref<string[]>([]);
function handleUpdateMenu(_key: string, item: MenuOption) {
const menuItem = item as App.GlobalMenuOption;
routerPush(menuItem.routePath);
}
function handleUpdateExpandedKeys(keys: string[]) {
expandedKeys.value = keys;
}
watch(
() => route.name,
() => {
expandedKeys.value = getActiveKeyPathsOfMenus(activeKey.value, menus.value);
},
{ immediate: true }
);
</script>
<style scoped></style>

View File

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

View File

@@ -1,22 +0,0 @@
<template>
<vertical-mix-sider v-if="isVerticalMix" class="global-sider" />
<vertical-sider v-else class="global-sider" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store';
import { VerticalMixSider, VerticalSider } from './components';
defineOptions({ name: 'GlobalSider' });
const theme = useThemeStore();
const isVerticalMix = computed(() => theme.layout.mode === 'vertical-mix');
</script>
<style scoped>
.global-sider {
box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
}
</style>

View File

@@ -1,4 +0,0 @@
import TabDetail from './tab-detail/index.vue';
import ReloadButton from './reload-button/index.vue';
export { TabDetail, ReloadButton };

View File

@@ -1,29 +0,0 @@
<template>
<hover-container class="w-64px h-full" tooltip-content="重新加载" placement="bottom-end" @click="handleRefresh">
<icon-mdi-refresh class="text-22px" :class="{ 'animate-spin': loading }" />
</hover-container>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useRouteStore } from '@/store';
import { useLoading } from '@/hooks';
defineOptions({ name: 'ReloadButton' });
const { reCacheRoute } = useRouteStore();
const route = useRoute();
const { loading, startLoading, endLoading } = useLoading();
async function handleRefresh() {
startLoading();
await reCacheRoute(route.name as AuthRoute.AllRouteKey);
setTimeout(() => {
endLoading();
}, 1000);
}
</script>
<style scoped></style>

View File

@@ -1,169 +0,0 @@
<template>
<n-dropdown
:show="dropdownVisible"
:options="options"
placement="bottom-start"
:x="x"
:y="y"
@clickoutside="hide"
@select="handleDropdown"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { DropdownOption } from 'naive-ui';
import { useAppStore, useTabStore } from '@/store';
import { useIconRender } from '@/composables';
defineOptions({ name: 'ContextMenu' });
interface Props {
/** 右键菜单可见性 */
visible?: boolean;
/** 当前路由路径 */
currentPath?: string;
/** 是否固定在tab卡不可关闭 */
affix?: boolean;
/** 鼠标x坐标 */
x: number;
/** 鼠标y坐标 */
y: number;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
currentPath: ''
});
interface Emits {
(e: 'update:visible', visible: boolean): void;
}
const emit = defineEmits<Emits>();
const app = useAppStore();
const tab = useTabStore();
const { iconRender } = useIconRender();
const dropdownVisible = computed({
get() {
return props.visible;
},
set(visible: boolean) {
emit('update:visible', visible);
}
});
function hide() {
dropdownVisible.value = false;
}
type DropdownKey =
| 'full-content'
| 'reload-current'
| 'close-current'
| 'close-other'
| 'close-left'
| 'close-right'
| 'close-all';
type Option = DropdownOption & {
key: DropdownKey;
};
const options = computed<Option[]>(() => [
{
label: '内容全屏',
key: 'full-content',
icon: iconRender({ icon: 'gridicons-fullscreen' })
},
{
label: '重新加载',
key: 'reload-current',
disabled: props.currentPath !== tab.activeTab,
icon: iconRender({ icon: 'ant-design:reload-outlined' })
},
{
label: '关闭',
key: 'close-current',
disabled: props.currentPath === tab.homeTab.fullPath || Boolean(props.affix),
icon: iconRender({ icon: 'ant-design:close-outlined' })
},
{
label: '关闭其他',
key: 'close-other',
icon: iconRender({ icon: 'ant-design:column-width-outlined' })
},
{
label: '关闭左侧',
key: 'close-left',
icon: iconRender({ icon: 'mdi:format-horizontal-align-left' })
},
{
label: '关闭右侧',
key: 'close-right',
icon: iconRender({ icon: 'mdi:format-horizontal-align-right' })
},
{
label: '关闭所有',
key: 'close-all',
icon: iconRender({ icon: 'ant-design:line-outlined' })
}
]);
const actionMap = new Map<DropdownKey, () => void>([
[
'full-content',
() => {
app.setContentFull(true);
}
],
[
'reload-current',
() => {
app.reloadPage();
}
],
[
'close-current',
() => {
tab.removeTab(props.currentPath);
}
],
[
'close-other',
() => {
tab.clearTab([props.currentPath]);
}
],
[
'close-left',
() => {
tab.clearLeftTab(props.currentPath);
}
],
[
'close-right',
() => {
tab.clearRightTab(props.currentPath);
}
],
[
'close-all',
() => {
tab.clearAllTab();
}
]
]);
function handleDropdown(optionKey: string) {
const key = optionKey as DropdownKey;
const actionFunc = actionMap.get(key);
if (actionFunc) {
actionFunc();
}
hide();
}
</script>
<style scoped></style>

View File

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

View File

@@ -1,132 +0,0 @@
<template>
<div ref="tabRef" class="flex h-full pr-18px" :class="[isChromeMode ? 'items-end' : 'items-center gap-12px']">
<PageTab
v-for="item in tab.tabs"
:key="item.fullPath"
:mode="theme.tab.mode"
:dark-mode="theme.darkMode"
:active="tab.activeTab === item.fullPath"
:active-color="theme.themeColor"
:closable="!(item.name === tab.homeTab.name || item.meta.affix)"
@click="tab.handleClickTab(item.fullPath)"
@close="tab.removeTab(item.fullPath)"
@contextmenu="handleContextMenu($event, item.fullPath, item.meta.affix)"
>
<template #prefix>
<svg-icon
:icon="item.meta.icon"
:local-icon="item.meta.localIcon"
class="inline-block align-text-bottom text-16px"
/>
</template>
{{ item.meta.i18nTitle ? $t(item.meta.i18nTitle) : item.meta.title }}
</PageTab>
</div>
<context-menu
:visible="dropdown.visible"
:current-path="dropdown.currentPath"
:affix="dropdown.affix"
:x="dropdown.x"
:y="dropdown.y"
@update:visible="handleDropdownVisible"
/>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { PageTab } from '@soybeanjs/vue-materials';
import { useTabStore, useThemeStore } from '@/store';
import { $t } from '@/locales';
import { ContextMenu } from './components';
defineOptions({ name: 'TabDetail' });
interface Emits {
(e: 'scroll', clientX: number): void;
}
const emit = defineEmits<Emits>();
const theme = useThemeStore();
const tab = useTabStore();
const isChromeMode = computed(() => theme.tab.mode === 'chrome');
// 获取当前激活的tab的clientX
const tabRef = ref<HTMLElement>();
async function getActiveTabClientX() {
await nextTick();
if (tabRef.value && tabRef.value.children.length && tabRef.value.children[tab.activeTabIndex]) {
const activeTabElement = tabRef.value.children[tab.activeTabIndex];
const { x, width } = activeTabElement.getBoundingClientRect();
const clientX = x + width / 2;
setTimeout(() => {
emit('scroll', clientX);
}, 50);
}
}
interface DropdownConfig {
visible: boolean;
affix: boolean;
x: number;
y: number;
currentPath: string;
}
const dropdown: DropdownConfig = reactive({
visible: false,
affix: false,
x: 0,
y: 0,
currentPath: ''
});
function setDropdown(config: Partial<DropdownConfig>) {
Object.assign(dropdown, config);
}
let isClickContextMenu = false;
function handleDropdownVisible(visible: boolean) {
if (!isClickContextMenu) {
setDropdown({ visible });
}
}
/** 点击右键菜单 */
async function handleContextMenu(e: MouseEvent, currentPath: string, affix?: boolean) {
e.preventDefault();
const { clientX, clientY } = e;
isClickContextMenu = true;
const DURATION = dropdown.visible ? 150 : 0;
setDropdown({ visible: false });
setTimeout(() => {
setDropdown({
visible: true,
x: clientX,
y: clientY,
currentPath,
affix
});
isClickContextMenu = false;
}, DURATION);
}
watch(
() => tab.activeTabIndex,
() => {
getActiveTabClientX();
},
{
immediate: true
}
);
</script>
<style scoped></style>

View File

@@ -1,65 +0,0 @@
<template>
<dark-mode-container class="global-tab flex-y-center w-full pl-16px" :style="{ height: theme.tab.height + 'px' }">
<div ref="bsWrapper" class="flex-1-hidden h-full">
<better-scroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: canClick }">
<tab-detail @scroll="handleScroll" />
</better-scroll>
</div>
<reload-button />
</dark-mode-container>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useElementBounding } from '@vueuse/core';
import { useTabStore, useThemeStore } from '@/store';
import { useDeviceInfo } from '@/composables';
import { ReloadButton, TabDetail } from './components';
defineOptions({ name: 'GlobalTab' });
const route = useRoute();
const theme = useThemeStore();
const tab = useTabStore();
const deviceInfo = useDeviceInfo();
const bsWrapper = ref<HTMLElement>();
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapper);
const bsScroll = ref<Expose.BetterScroll>();
const canClick = Boolean(deviceInfo.device.type);
function handleScroll(clientX: number) {
const currentX = clientX - bsWrapperLeft.value;
const deltaX = currentX - bsWrapperWidth.value / 2;
if (bsScroll.value) {
const { maxScrollX, x: leftX } = bsScroll.value.instance;
const rightX = maxScrollX - leftX;
const update = deltaX > 0 ? Math.max(-deltaX, rightX) : Math.min(-deltaX, -leftX);
bsScroll.value?.instance.scrollBy(update, 0, 300);
}
}
function init() {
tab.iniTabStore(route);
}
watch(
() => route.fullPath,
() => {
tab.addTab(route);
tab.setActiveTab(route.fullPath);
}
);
// 初始化
init();
</script>
<style scoped>
.global-tab {
box-shadow: 0 1px 2px rgb(0 21 41 / 8%);
}
</style>

View File

@@ -1,9 +0,0 @@
import SettingDrawer from './setting-drawer/index.vue';
import GlobalHeader from './global-header/index.vue';
import GlobalTab from './global-tab/index.vue';
import GlobalSider from './global-sider/index.vue';
import GlobalContent from './global-content/index.vue';
import GlobalFooter from './global-footer/index.vue';
import GlobalLogo from './global-logo/index.vue';
export { SettingDrawer, GlobalHeader, GlobalTab, GlobalSider, GlobalContent, GlobalFooter, GlobalLogo };

View File

@@ -1,55 +0,0 @@
<template>
<n-divider title-placement="center">{{ $t('layout.settingDrawer.themeModeTitle') }}</n-divider>
<n-space vertical size="large">
<setting-menu :label="$t('layout.settingDrawer.darkMode')">
<n-switch :value="theme.darkMode" @update:value="theme.setDarkMode">
<template #checked>
<icon-mdi-white-balance-sunny class="text-14px text-white" />
</template>
<template #unchecked>
<icon-mdi-moon-waning-crescent class="text-14px text-white" />
</template>
</n-switch>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.followSystemTheme')">
<n-switch :value="theme.followSystemTheme" @update:value="theme.setFollowSystemTheme">
<template #checked>
<icon-ic-baseline-do-not-disturb class="text-14px text-white" />
</template>
<template #unchecked>
<icon-ic-round-hdr-auto class="text-14px text-white" />
</template>
</n-switch>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.isCustomizeDarkModeTransition')">
<n-switch :value="theme.isCustomizeDarkModeTransition" @update:value="theme.setIsCustomizeDarkModeTransition">
<template #checked>
<icon-ic-baseline-do-not-disturb class="text-14px text-white" />
</template>
<template #unchecked>
<icon-ic-round-hdr-auto class="text-14px text-white" />
</template>
</n-switch>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.sider.inverted')">
<n-switch :value="theme.sider.inverted" @update:value="theme.setSiderInverted" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.header.inverted')">
<n-switch :value="theme.header.inverted" @update:value="theme.setHeaderInverted" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.footer.inverted')">
<n-switch :value="theme.footer.inverted" @update:value="theme.setFooterInverted" />
</setting-menu>
</n-space>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
import { $t } from '@/locales';
import SettingMenu from '../setting-menu/index.vue';
defineOptions({ name: 'DarkMode' });
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,21 +0,0 @@
<template>
<n-button
type="primary"
:class="[{ '!right-330px': app.settingDrawerVisible }, app.settingDrawerVisible ? 'ease-out' : 'ease-in']"
class="fixed top-360px right-14px z-10000 w-42px h-42px !p-0 transition-all duration-300"
@click="app.toggleSettingDrawerVisible"
>
<icon-ant-design-close-outlined v-if="app.settingDrawerVisible" class="text-24px" />
<icon-ant-design-setting-outlined v-else class="text-24px" />
</n-button>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store';
defineOptions({ name: 'DrawerButton' });
const app = useAppStore();
</script>
<style scoped></style>

View File

@@ -1,9 +0,0 @@
import DrawerButton from './drawer-button/index.vue';
import DarkMode from './dark-mode/index.vue';
import LayoutMode from './layout-mode/index.vue';
import ThemeColorSelect from './theme-color-select/index.vue';
import PageFunc from './page-func/index.vue';
import PageView from './page-view/index.vue';
import ThemeConfig from './theme-config/index.vue';
export { DrawerButton, DarkMode, LayoutMode, ThemeColorSelect, PageFunc, PageView, ThemeConfig };

View File

@@ -1,4 +0,0 @@
import LayoutCheckbox from './layout-checkbox.vue';
import LayoutCard from './layout-card.vue';
export { LayoutCheckbox, LayoutCard };

View File

@@ -1,81 +0,0 @@
<template>
<div
class="border-2px rounded-6px cursor-pointer hover:border-primary"
:class="[checked ? 'border-primary' : 'border-transparent']"
>
<n-tooltip :placement="activeConfig.placement" trigger="hover">
<template #trigger>
<div
class="layout-card__shadow gap-6px w-96px h-64px p-6px rd-4px"
:class="[mode.includes('vertical') ? 'flex' : 'flex-col']"
>
<slot></slot>
</div>
</template>
<span>{{ label }}</span>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({ name: 'LayoutCard' });
interface Props {
/** 布局模式 */
mode: UnionKey.ThemeLayoutMode;
/** 布局模式文本 */
label: string;
/** 选中状态 */
checked: boolean;
}
const props = defineProps<Props>();
type LayoutConfig = Record<
UnionKey.ThemeLayoutMode,
{
placement: PopoverPlacement;
headerClass: string;
menuClass: string;
mainClass: string;
}
>;
const layoutConfig: LayoutConfig = {
vertical: {
placement: 'bottom-start',
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-end',
headerClass: '',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4'
}
};
const activeConfig = computed(() => layoutConfig[props.mode]);
</script>
<style scoped>
.layout-card__shadow {
box-shadow: 0 1px 2.5px rgba(0, 0, 0, 0.18);
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<div
class="border-2px rounded-6px cursor-pointer hover:border-primary"
:class="[checked ? 'border-primary' : 'border-transparent']"
>
<n-tooltip :placement="activeConfig.placement" trigger="hover">
<template #trigger>
<div class="layout-checkbox__shadow relative w-56px h-48px bg-white rounded-4px overflow-hidden">
<div class="absolute-lt bg-#273352" :class="activeConfig.menuClass"></div>
<div class="absolute-rb bg-#f0f2f5" :class="activeConfig.mainClass"></div>
</div>
</template>
<span>{{ label }}</span>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({ name: 'LayoutCheckbox' });
interface Props {
/** 布局模式 */
mode: UnionKey.ThemeLayoutMode;
/** 布局模式文本 */
label: string;
/** 选中状态 */
checked: boolean;
}
const props = defineProps<Props>();
type LayoutConfig = Record<
UnionKey.ThemeLayoutMode,
{
placement: PopoverPlacement;
menuClass: string;
mainClass: string;
}
>;
const layoutConfig: LayoutConfig = {
vertical: {
placement: 'bottom-start',
menuClass: 'w-1/3 h-full',
mainClass: 'w-2/3 h-3/4'
},
'vertical-mix': {
placement: 'bottom',
menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4'
},
horizontal: {
placement: 'bottom',
menuClass: 'w-full h-1/4',
mainClass: 'w-full h-3/4'
},
'horizontal-mix': {
placement: 'bottom-end',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4'
}
};
const activeConfig = computed(() => layoutConfig[props.mode]);
</script>
<style scoped>
.layout-checkbox__shadow {
box-shadow: 0 1px 2.5px rgba(0, 0, 0, 0.18);
}
</style>

View File

@@ -1,58 +0,0 @@
<template>
<n-divider title-placement="center">{{ $t('layout.settingDrawer.layoutModelTitle') }}</n-divider>
<n-space justify="space-around" :wrap="true" :size="24" class="px-12px">
<layout-card
v-for="item in theme.layout.modeList"
:key="item.value"
:mode="item.value"
:label="item.label"
:checked="item.value === theme.layout.mode"
@click="theme.setLayoutMode(item.value)"
>
<template v-if="item.value === 'vertical'">
<div class="w-18px h-full bg-primary:50 rd-4px"></div>
<div class="flex-1 flex-col gap-6px">
<div class="h-16px bg-primary rd-4px"></div>
<div class="flex-1 bg-primary:25 rd-4px"></div>
</div>
</template>
<template v-if="item.value === 'vertical-mix'">
<div class="w-8px h-full bg-primary:50 rd-4px"></div>
<div class="w-16px h-full bg-primary:50 rd-4px"></div>
<div class="flex-1 flex-col gap-6px">
<div class="h-16px bg-primary rd-4px"></div>
<div class="flex-1 bg-primary:25 rd-4px"></div>
</div>
</template>
<template v-if="item.value === 'horizontal'">
<div class="h-16px bg-primary rd-4px"></div>
<div class="flex-1 flex gap-6px">
<div class="flex-1 bg-primary:25 rd-4px"></div>
</div>
</template>
<template v-if="item.value === 'horizontal-mix'">
<div class="h-16px bg-primary rd-4px"></div>
<div class="flex-1 flex gap-6px">
<div class="w-18px bg-primary:50 rd-4px"></div>
<div class="flex-1 bg-primary:25 rd-4px"></div>
</div>
</template>
</layout-card>
</n-space>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/store';
import { $t } from '@/locales';
import { LayoutCard } from './components';
defineOptions({ name: 'LayoutMode' });
const theme = useThemeStore();
</script>
<style scoped>
.layout-card__shadow {
box-shadow: 0 1px 2.5px rgba(0, 0, 0, 0.18);
}
</style>

View File

@@ -1,86 +0,0 @@
<template>
<n-divider title-placement="center">{{ $t('layout.settingDrawer.pageFunctionsTitle') }}</n-divider>
<n-space vertical size="large">
<setting-menu :label="$t('layout.settingDrawer.scrollMode')">
<n-select
class="w-120px"
size="small"
:value="theme.scrollMode"
:options="theme.scrollModeList"
@update:value="theme.setScrollMode"
/>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.fixedHeaderAndTab')">
<n-switch :value="theme.fixedHeaderAndTab" @update:value="theme.setIsFixedHeaderAndTab" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.menu.horizontalPosition')">
<n-select
class="w-120px"
size="small"
:value="theme.menu.horizontalPosition"
:options="theme.menu.horizontalPositionList"
@update:value="theme.setHorizontalMenuPosition"
/>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.header.height')">
<n-input-number
class="w-120px"
size="small"
:value="theme.header.height"
:step="1"
@update:value="theme.setHeaderHeight"
/>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.tab.height')">
<n-input-number
class="w-120px"
size="small"
:value="theme.tab.height"
:step="1"
@update:value="theme.setTabHeight"
/>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.tab.isCache')">
<n-switch :value="theme.tab.isCache" @update:value="theme.setTabIsCache" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.sider.width')">
<n-input-number
class="w-120px"
size="small"
:value="theme.sider.width"
:step="10"
@update:value="theme.setSiderWidth"
/>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.sider.mixWidth')">
<n-input-number
class="w-120px"
size="small"
:value="theme.sider.mixWidth"
:step="5"
@update:value="theme.setMixSiderWidth"
/>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.footer.visible')">
<n-switch :value="theme.footer.visible" @update:value="theme.setFooterVisible" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.footer.fixed')">
<n-switch :value="theme.footer.fixed" @update:value="theme.setFooterIsFixed" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.footer.right')">
<n-switch :value="theme.footer.right" @update:value="theme.setFooterIsRight" />
</setting-menu>
</n-space>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
import { $t } from '@/locales';
import SettingMenu from '../setting-menu/index.vue';
defineOptions({ name: 'PageFunc' });
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,47 +0,0 @@
<template>
<n-divider title-placement="center">{{ $t('layout.settingDrawer.pageViewTitle') }}</n-divider>
<n-space vertical size="large">
<setting-menu :label="$t('layout.settingDrawer.header.crumb.visible')">
<n-switch :value="theme.header.crumb.visible" @update:value="theme.setHeaderCrumbVisible" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.header.crumb.icon')">
<n-switch :value="theme.header.crumb.showIcon" @update:value="theme.setHeaderCrumbIconVisible" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.tab.visible')">
<n-switch :value="theme.tab.visible" @update:value="theme.setTabVisible" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.tab.modeList.mode')">
<n-select
class="w-120px"
size="small"
:value="theme.tab.mode"
:options="theme.tab.modeList"
@update:value="theme.setTabMode"
/>
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.page.animate')">
<n-switch :value="theme.page.animate" @update:value="theme.setPageIsAnimate" />
</setting-menu>
<setting-menu :label="$t('layout.settingDrawer.page.animateMode')">
<n-select
class="w-120px"
size="small"
:value="theme.page.animateMode"
:options="theme.page.animateModeList"
@update:value="theme.setPageAnimateMode"
/>
</setting-menu>
</n-space>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
import { $t } from '@/locales';
import SettingMenu from '../setting-menu/index.vue';
defineOptions({ name: 'PageView' });
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@@ -1,19 +0,0 @@
<template>
<div class="flex-y-center justify-between">
<span>{{ label }}</span>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'SettingMenu' });
interface Props {
/** 文本 */
label: string;
}
defineProps<Props>();
</script>
<style scoped></style>

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex-center w-20px h-20px rounded-2px shadow cursor-pointer" :style="{ backgroundColor: color }">
<icon-ic-outline-check v-if="checked" :class="[iconClass, isWhite ? 'text-gray-700' : 'text-white']" />
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
defineOptions({ name: 'ColorCheckbox' });
interface Props {
/** 颜色 */
color: string;
/** 是否选中 */
checked: boolean;
/** 图标的class */
iconClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
iconClass: 'text-14px'
});
const whiteColors = ['#ffffff', '#fff', 'rgb(255,255,255)'];
const isWhite = computed(() => whiteColors.includes(props.color));
</script>
<style scoped></style>

View File

@@ -1,51 +0,0 @@
<template>
<n-modal :show="visible" preset="card" class="w-640px h-480px" :z-index="10001" @close="handleClose">
<div class="flex-x-center">
<n-gradient-text type="primary" :size="24">中国传统颜色</n-gradient-text>
</div>
<n-tabs>
<n-tab-pane v-for="item in traditionColors" :key="item.label" :name="item.label" :tab="item.label">
<n-grid :cols="8" :x-gap="16" :y-gap="8">
<n-grid-item v-for="i in item.data" :key="i.label">
<color-checkbox
class="!w-full !h-36px !rounded-4px"
:color="i.color"
:checked="i.color === theme.themeColor"
icon-class="text-20px"
@click="theme.setThemeColor(i.color)"
/>
<p class="text-center">{{ i.label }}</p>
</n-grid-item>
</n-grid>
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script setup lang="ts">
import { traditionColors } from '@/settings';
import { useThemeStore } from '@/store';
import ColorCheckbox from './color-checkbox.vue';
defineOptions({ name: 'ColorModal' });
interface Props {
visible: boolean;
}
defineProps<Props>();
interface Emits {
(e: 'close'): void;
}
const emit = defineEmits<Emits>();
const theme = useThemeStore();
function handleClose() {
emit('close');
}
</script>
<style scoped></style>

View File

@@ -1,4 +0,0 @@
import ColorCheckbox from './color-checkbox.vue';
import ColorModal from './color-modal.vue';
export { ColorCheckbox, ColorModal };

View File

@@ -1,35 +0,0 @@
<template>
<n-divider title-placement="center">{{ $t('layout.settingDrawer.systemThemeTitle') }}</n-divider>
<n-grid :cols="8" :x-gap="8" :y-gap="12">
<n-grid-item v-for="color in theme.themeColorList" :key="color" class="flex-x-center">
<color-checkbox :color="color" :checked="color === theme.themeColor" @click="theme.setThemeColor(color)" />
</n-grid-item>
</n-grid>
<n-space :vertical="true" class="pt-12px">
<n-color-picker :value="theme.themeColor" :show-alpha="false" @update-value="theme.setThemeColor" />
<n-button :block="true" :type="otherColorBtnType" @click="openModal">
{{ $t('layout.settingDrawer.systemTheme.moreColors') }}
</n-button>
</n-space>
<color-modal :visible="visible" @close="closeModal" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { isInTraditionColors } from '@/settings';
import { useThemeStore } from '@/store';
import { useBoolean } from '@/hooks';
import { $t } from '@/locales';
import { ColorCheckbox, ColorModal } from './components';
defineOptions({ name: 'ThemeColorSelect' });
const theme = useThemeStore();
const { bool: visible, setTrue: openModal, setFalse: closeModal } = useBoolean();
const isInOther = computed(() => isInTraditionColors(theme.themeColor));
const otherColorBtnType = computed(() => (isInOther.value ? 'primary' : 'default'));
</script>
<style scoped></style>

View File

@@ -1,65 +0,0 @@
<template>
<n-divider title-placement="center">{{ $t('layout.settingDrawer.themeConfiguration.title') }}</n-divider>
<textarea id="themeConfigCopyTarget" v-model="dataClipboardText" class="absolute opacity-0" />
<n-space vertical>
<div ref="copyRef" data-clipboard-target="#themeConfigCopyTarget">
<n-button type="primary" :block="true">{{ $t('layout.settingDrawer.themeConfiguration.copy') }}</n-button>
</div>
<n-button type="warning" :block="true" @click="handleResetConfig">
{{ $t('layout.settingDrawer.themeConfiguration.reset') }}
</n-button>
</n-space>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import Clipboard from 'clipboard';
import { useThemeStore } from '@/store';
import { $t } from '@/locales';
defineOptions({ name: 'ThemeConfig' });
const theme = useThemeStore();
const copyRef = ref<HTMLElement>();
const dataClipboardText = ref(getClipboardText());
function getClipboardText() {
return JSON.stringify(theme.$state);
}
function handleResetConfig() {
theme.resetThemeStore();
window.$message?.success($t('layout.settingDrawer.themeConfiguration.resetSuccess'));
}
function clipboardEventListener() {
if (!copyRef.value) return;
const copy = new Clipboard(copyRef.value);
copy.on('success', () => {
window.$dialog?.success({
title: $t('layout.settingDrawer.themeConfiguration.operateSuccess'),
content: $t('layout.settingDrawer.themeConfiguration.copySuccess'),
positiveText: $t('layout.settingDrawer.themeConfiguration.confirmCopy')
});
});
}
const stopHandle = watch(
() => theme.$state,
() => {
dataClipboardText.value = getClipboardText();
},
{ deep: true }
);
onMounted(() => {
clipboardEventListener();
});
onUnmounted(() => {
stopHandle();
});
</script>
<style scoped></style>

View File

@@ -1,27 +0,0 @@
<template>
<n-drawer :show="app.settingDrawerVisible" display-directive="show" :width="330" @mask-click="app.closeSettingDrawer">
<n-drawer-content :title="$t('layout.settingDrawer.title')" :native-scrollbar="false">
<dark-mode />
<layout-mode />
<theme-color-select />
<page-func />
<page-view />
<theme-config />
</n-drawer-content>
</n-drawer>
<drawer-button v-if="showButton" />
</template>
<script setup lang="ts">
import { useAppStore } from '@/store';
import { $t } from '@/locales';
import { DarkMode, DrawerButton, LayoutMode, PageFunc, PageView, ThemeColorSelect, ThemeConfig } from './components';
defineOptions({ name: 'SettingDrawer' });
const app = useAppStore();
const showButton = import.meta.env.DEV || import.meta.env.VITE_VERCEL === 'Y';
</script>
<style scoped></style>

View File

@@ -0,0 +1,47 @@
import { ref, computed, 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

@@ -1,4 +0,0 @@
const BasicLayout = () => import('./basic-layout/index.vue');
const BlankLayout = () => import('./blank-layout/index.vue');
export { BasicLayout, BlankLayout };

View File

@@ -0,0 +1,47 @@
<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>
<NBreadcrumb v-if="themeStore.header.breadcrumb.visible">
<!-- define component: 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: BreadcrumbContent -->
<NBreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
<NDropdown v-if="item.options?.length" :options="item.options" @select="handleClickMenu">
<BreadcrumbContent :breadcrumb="item" />
</NDropdown>
<BreadcrumbContent v-else :breadcrumb="item" />
</NBreadcrumbItem>
</NBreadcrumb>
</template>
<style scoped></style>

View File

@@ -0,0 +1,47 @@
<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,11 @@
<script setup lang="ts">
defineOptions({
name: 'GlobalFooter'
});
</script>
<template>
<DarkModeContainer class="h-full"></DarkModeContainer>
</template>
<style scoped></style>

View File

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

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { VNode } from 'vue';
import { useSvgIconRender } from '@sa/hooks';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
import SvgIcon from '@/components/custom/svg-icon.vue';
defineOptions({
name: 'UserAvatar'
});
const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush();
const { SvgIconVNode } = useSvgIconRender(SvgIcon);
function loginOrRegister() {
toLogin();
}
type DropdownKey = 'user-center' | 'logout';
type DropdownOption =
| {
key: DropdownKey;
label: string;
icon?: () => VNode;
}
| {
type: 'divider';
key: string;
};
const options = computed(() => {
const opts: DropdownOption[] = [
{
label: $t('common.userCenter'),
key: 'user-center',
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
},
{
type: 'divider',
key: 'divider'
},
{
label: $t('common.logout'),
key: 'logout',
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 })
}
];
return opts;
});
function logout() {
window.$dialog?.info({
title: $t('common.tip'),
content: $t('common.logoutConfirm'),
positiveText: $t('common.confirm'),
negativeText: $t('common.cancel'),
onPositiveClick: () => {
authStore.resetStore();
}
});
}
function handleDropdown(key: DropdownKey) {
if (key === 'logout') {
logout();
} else {
routerPushByKey(key);
}
}
</script>
<template>
<NButton v-if="!authStore.isLogin" quaternary @click="loginOrRegister">
{{ $t('page.login.common.loginOrRegister') }}
</NButton>
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
<div>
<ButtonIcon>
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
</ButtonIcon>
</div>
</NDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,76 @@
<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 ThemeButton from './components/theme-button.vue';
import UserAvatar from './components/user-avatar.vue';
import { useMixMenuContext } from '../../hooks/use-mix-menu';
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { isFullscreen, toggle } = useFullscreen();
const { menus } = useMixMenuContext();
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 headerMenus = computed(() => {
if (themeStore.layout.mode === 'horizontal') {
return routeStore.menus;
}
if (themeStore.layout.mode === 'horizontal-mix') {
return menus.value;
}
return [];
});
</script>
<template>
<DarkModeContainer class="flex-y-center h-full 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="flex-1-hidden flex-y-center h-full">
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
</div>
<div class="flex-y-center justify-end h-full">
<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,29 @@
<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="flex-center w-full nowrap-hidden">
<SystemLogo class="text-32px text-primary" />
<h2 v-show="showTitle" class="pl-8px text-16px font-bold text-primary transition duration-300 ease-in-out">
{{ $t('system.title') }}
</h2>
</RouterLink>
</template>
<style scoped></style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { MenuProps, MentionOption } from 'naive-ui';
import { SimpleScrollbar } from '@sa/materials';
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?: MenuProps['mode'];
menus: App.Global.Menu[];
}
const props = withDefaults(defineProps<Props>(), {
mode: 'vertical'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKey } = useRouterPush();
const naiveMenus = computed(() => props.menus as unknown as MentionOption[]);
const isHorizontal = computed(() => props.mode === 'horizontal');
const siderCollapse = computed(() => themeStore.layout.mode === 'vertical' && appStore.siderCollapse);
const menuHeightStyle = computed(() =>
isHorizontal.value ? { '--n-item-height': `${themeStore.header.height}px` } : {}
);
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (isHorizontal.value || siderCollapse.value || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
function handleClickMenu(key: RouteKey) {
routerPushByKey(key);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
:mode="mode"
:value="selectedKey"
:collapsed="siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="naiveMenus"
:inverted="darkTheme"
:inline-indent="18"
class="transition-300"
:style="menuHeightStyle"
@update:value="handleClickMenu"
/>
</SimpleScrollbar>
</template>
<style scoped></style>

View File

@@ -0,0 +1,113 @@
<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>();
interface Emits {
(e: 'select', menu: App.Global.Menu): boolean;
}
const emit = defineEmits<Emits>();
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="flex-vertical-center mx-4px mb-6px py-8px px-4px rounded-8px bg-transparent transition-300 cursor-pointer 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 text-center ellipsis-text text-12px transition-height-300"
:class="[isMini ? 'h-0 pt-0' : 'h-24px pt-4px']"
>
{{ label }}
</p>
</div>
</DefineMixMenuItem>
<!-- template -->
<div class="flex-1-hidden flex-vertical-stretch h-full">
<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 FirstLevelMenu from './first-level-menu.vue';
import { useMixMenuContext } from '../../hooks/use-mix-menu';
import { useRouterPush } from '@/hooks/common/router';
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 FirstLevelMenu from './first-level-menu.vue';
import BaseMenu from './base-menu.vue';
import { useMixMenu } from '../../hooks/use-mix-menu';
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="flex h-full" @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 flex-vertical-stretch h-full nowrap-hidden transition-all-300 shadow-sm"
: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="text-primary pl-8px text-16px 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="flex-vertical-stretch wh-full 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,128 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { VNode } from 'vue';
import { $t } from '@/locales';
import { useSvgIconRender } from '@sa/hooks';
import { useTabStore } from '@/store/modules/tab';
import SvgIcon from '@/components/custom/svg-icon.vue';
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 visible = defineModel<boolean>('visible');
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs } = useTabStore();
const { SvgIconVNode } = useSvgIconRender(SvgIcon);
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;
});
function hideDropdown() {
visible.value = false;
}
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>
<NDropdown
:show="visible"
placement="bottom-start"
trigger="manual"
:x="x"
:y="y"
:options="options"
@clickoutside="hideDropdown"
@select="handleDropdown"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { ref, reactive, watch, nextTick } 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)) {
disabledKeys.push('closeCurrent');
}
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);
}
interface DropdownConfig {
visible: boolean;
x: number;
y: number;
tabId: string;
}
const dropdown: DropdownConfig = reactive({
visible: false,
x: 0,
y: 0,
tabId: ''
});
function setDropdown(config: Partial<DropdownConfig>) {
Object.assign(dropdown, config);
}
let isClickContextMenu = false;
function handleDropdownVisible(visible: boolean) {
if (!isClickContextMenu) {
setDropdown({ visible });
}
}
async function handleContextMenu(e: MouseEvent, tabId: string) {
e.preventDefault();
const { clientX, clientY } = e;
isClickContextMenu = true;
const DURATION = dropdown.visible ? 150 : 0;
setDropdown({ visible: false });
setTimeout(() => {
setDropdown({
visible: true,
x: clientX,
y: clientY,
tabId
});
isClickContextMenu = false;
}, DURATION);
}
function init() {
tabStore.initTabStore(route);
}
// watch
watch(
() => route.fullPath,
() => {
tabStore.addTab(route);
}
);
watch(
() => tabStore.activeTabId,
() => {
scrollToActiveTab();
}
);
// init
init();
</script>
<template>
<DarkModeContainer class="flex-y-center wh-full px-16px shadow-tab">
<div ref="bsWrapper" class="flex-1-hidden h-full">
<BetterScroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: appStore.isMobile }">
<div
ref="tabRef"
class="flex h-full 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>
{{ tab.label }}
</PageTab>
</div>
</BetterScroll>
</div>
<ReloadButton :loading="!appStore.reloadFlag" @click="refresh" />
<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"
></ContextMenu>
</template>
<style scoped></style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { PopoverPlacement } from 'naive-ui';
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: PopoverPlacement;
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 border-2px rounded-6px cursor-pointer hover:border-primary"
:class="[mode === key ? 'border-primary' : 'border-transparent']"
@click="handleChangeMode(key)"
>
<NTooltip :placement="item.placement">
<template #trigger>
<div
class="gap-6px w-96px h-64px p-6px rd-4px shadow dark:shadow-coolGray-5"
:class="[key.includes('vertical') ? 'flex' : 'flex-vertical']"
>
<slot :name="key"></slot>
</div>
</template>
{{ $t(themeLayoutModeRecord[key]) }}
</NTooltip>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
defineOptions({
name: 'SettingItem'
});
interface Props {
/**
* label
*/
label: string;
}
defineProps<Props>();
</script>
<template>
<div class="flex-y-center justify-between w-full">
<div>
<span class="text-base_text pr-8px">{{ label }}</span>
<slot name="suffix"></slot>
</div>
<slot></slot>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,31 @@
<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>
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="378">
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
<DarkMode />
<LayoutMode />
<ThemeColor />
<PageFun />
<template #footer>
<ConfigOperation />
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, onMounted } 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', () => {
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);
}
onMounted(() => {
initClipboard();
});
</script>
<template>
<div class="flex justify-between w-full">
<NButton type="error" ghost @click="handleReset">{{ $t('theme.configOperation.resetConfig') }}</NButton>
<div ref="domRef">
<NButton type="primary">{{ $t('theme.configOperation.copyConfig') }}</NButton>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,62 @@
<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);
}
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
</script>
<template>
<NDivider>{{ $t('theme.themeSchema.title') }}</NDivider>
<div class="flex-vertical-stretch gap-16px">
<div class="i-flex-center">
<NTabs type="segment" size="small" :value="themeStore.themeScheme" @update:value="handleSegmentChange">
<NTab v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
<SvgIcon :icon="icons[key]" class="h-28px text-icon-small" />
</NTab>
</NTabs>
</div>
<Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
<NSwitch v-model:value="themeStore.sider.inverted" />
</SettingItem>
</Transition>
</div>
</template>
<style scoped>
.sider-inverted-enter-active {
height: 22px;
transition: all 0.3s ease-in-out;
}
.sider-inverted-leave-active {
height: 22px;
transition: all 0.3s ease-in-out;
}
.sider-inverted-enter-from,
.sider-inverted-leave-to {
transform: translateX(20px);
opacity: 0;
height: 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>
<NDivider>{{ $t('theme.layoutMode.title') }}</NDivider>
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
<template #vertical>
<div class="layout-sider w-18px h-full"></div>
<div class="vertical-wrapper">
<div class="layout-header"></div>
<div class="layout-main"></div>
</div>
</template>
<template #vertical-mix>
<div class="layout-sider w-8px h-full"></div>
<div class="layout-sider w-16px h-full"></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-vertical gap-6px;
}
.horizontal-wrapper {
--uno: flex-1 flex gap-6px;
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
import { useThemeStore } from '@/store/modules/theme';
import { themeScrollModeOptions, themePageAnimationModeOptions, 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');
function translateOptions(options: Common.Option<string>[]) {
return options.map(option => ({
...option,
label: $t(option.label as App.I18n.I18nKey)
}));
}
</script>
<template>
<NDivider>{{ $t('theme.pageFunTitle') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-vertical-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
></NSelect>
</SettingItem>
<SettingItem key="1-1" :label="$t('theme.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<NInputNumber v-model:value="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')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="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')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="7" :label="$t('theme.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
<NInputNumber v-model:value="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')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
transition: all 0.3s ease-in-out;
}
.setting-list-enter-from,
.setting-list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.setting-list-leave-active {
position: 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>
<NDivider>{{ $t('theme.themeColor.title') }}</NDivider>
<div class="flex-vertical-stretch gap-12px">
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
<template v-if="key === 'info'" #suffix>
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
{{ $t('theme.themeColor.followPrimary') }}
</NCheckbox>
</template>
<ColorPicker
:color="themeStore.themeColors[key]"
:disabled="key === 'info' && themeStore.isInfoFollowPrimary"
@update:color="handleUpdateColor($event, key)"
/>
</SettingItem>
</div>
</template>
<style scoped></style>