refactor(projects): 精简版+动态路由权限初步

This commit is contained in:
Soybean
2022-01-03 22:20:10 +08:00
parent 7a0648dba5
commit de2057f141
354 changed files with 2053 additions and 22117 deletions

View File

@ -1,22 +0,0 @@
<template>
<n-layout ref="scrollbar" :native-scrollbar="false" :content-style="scrollbarContentStyle" class="h-full">
<n-layout-header :inverted="headerInverted" :position="headerPosition" :class="{ 'z-11': theme.fixedHeaderAndTab }">
<global-header :show-logo="true" :show-menu-collape="false" :show-menu="true" class="relative z-2" />
<global-tab v-if="theme.multiTabStyle.visible" />
</n-layout-header>
<space-placeholder />
<global-content />
<global-footer />
</n-layout>
</template>
<script setup lang="ts">
import { NLayout, NLayoutHeader } from 'naive-ui';
import { useThemeStore } from '@/store';
import { useLayoutConfig } from '@/composables';
import { GlobalHeader, GlobalContent, GlobalFooter, GlobalTab, SpacePlaceholder } from '@/layouts/common';
const theme = useThemeStore();
const { headerInverted, headerPosition, scrollbarContentStyle, scrollbar } = useLayoutConfig();
</script>
<style scoped></style>

View File

@ -1,58 +0,0 @@
<template>
<n-layout :native-scrollbar="false" :content-style="flexColumnStyle" class="h-full">
<n-layout-header :inverted="headerInverted" position="absolute" class="z-13">
<global-header :show-logo="true" :show-menu-collape="true" :show-menu="false" class="relative z-2" />
</n-layout-header>
<n-layout :has-sider="true" class="h-full">
<n-layout-sider
v-bind="globalSiderClassAndStyle"
:content-style="flexColumnStyle"
:inverted="siderInverted"
collapse-mode="width"
:collapsed="app.menu.collapsed"
:collapsed-width="theme.menuStyle.collapsedWidth"
:width="siderMenuWidth"
:native-scrollbar="false"
@collapse="handleMenuCollapse(true)"
@expand="handleMenuCollapse(false)"
>
<space-placeholder :remove-tab="true" />
<n-scrollbar class="flex-1-hidden">
<global-menu />
</n-scrollbar>
</n-layout-sider>
<n-layout-content ref="scrollbar" :native-scrollbar="false" :content-style="scrollbarContentStyle">
<global-tab
v-if="theme.multiTabStyle.visible"
class="absolute left-0 w-full z-11 bg-white dark:bg-dark transition-background-color duration-300 ease-in-out"
:style="{ top: headerHeight }"
/>
<space-placeholder />
<global-content />
<global-footer />
</n-layout-content>
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import { NLayout, NLayoutContent, NLayoutSider, NLayoutHeader, NScrollbar } from 'naive-ui';
import { useThemeStore, useAppStore } from '@/store';
import { useLayoutConfig } from '@/composables';
import { GlobalHeader, GlobalContent, GlobalFooter, GlobalTab, GlobalMenu, SpacePlaceholder } from '@/layouts/common';
const theme = useThemeStore();
const app = useAppStore();
const { handleMenuCollapse } = useAppStore();
const {
headerInverted,
siderInverted,
siderMenuWidth,
globalSiderClassAndStyle,
flexColumnStyle,
scrollbarContentStyle,
headerHeight,
scrollbar
} = useLayoutConfig();
</script>
<style scoped></style>

View File

@ -1,64 +0,0 @@
<template>
<n-layout :has-sider="true" class="h-full">
<n-layout-sider
v-bind="globalSiderClassAndStyle"
:content-style="flexColumnStyle"
:inverted="siderInverted"
collapse-mode="width"
:collapsed="app.menu.collapsed"
:collapsed-width="theme.menuStyle.collapsedWidth"
:width="siderMenuWidth"
:native-scrollbar="false"
@collapse="handleMenuCollapse(true)"
@expand="handleMenuCollapse(false)"
>
<global-logo :show-title="!app.menu.collapsed" />
<n-scrollbar class="flex-1-hidden">
<global-menu />
</n-scrollbar>
</n-layout-sider>
<n-layout-content ref="scrollbar" :native-scrollbar="false" :content-style="scrollbarContentStyle">
<n-layout-header
:inverted="headerInverted"
:position="headerPosition"
:class="{ 'z-11': theme.fixedHeaderAndTab }"
>
<global-header :show-logo="false" :show-menu-collape="true" :show-menu="false" class="relative z-2" />
<global-tab v-if="theme.multiTabStyle.visible" />
</n-layout-header>
<space-placeholder />
<global-content />
<global-footer />
</n-layout-content>
</n-layout>
</template>
<script setup lang="ts">
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NScrollbar } from 'naive-ui';
import { useThemeStore, useAppStore } from '@/store';
import { useLayoutConfig } from '@/composables';
import {
GlobalHeader,
GlobalContent,
GlobalFooter,
GlobalTab,
SpacePlaceholder,
GlobalLogo,
GlobalMenu
} from '@/layouts/common';
const theme = useThemeStore();
const app = useAppStore();
const { handleMenuCollapse } = useAppStore();
const {
siderInverted,
siderMenuWidth,
headerInverted,
headerPosition,
globalSiderClassAndStyle,
flexColumnStyle,
scrollbarContentStyle,
scrollbar
} = useLayoutConfig();
</script>
<style scoped></style>

View File

@ -1,30 +0,0 @@
<template>
<n-layout :has-sider="true" class="h-full">
<mix-sider v-bind="globalSiderClassAndStyle" />
<n-layout-content ref="scrollbar" :native-scrollbar="false" :content-style="scrollbarContentStyle">
<n-layout-header
:inverted="headerInverted"
:position="headerPosition"
:class="{ 'z-11': theme.fixedHeaderAndTab }"
>
<global-header :show-logo="false" :show-menu-collape="false" :show-menu="false" class="relative z-2" />
<global-tab v-if="theme.multiTabStyle.visible" />
</n-layout-header>
<space-placeholder />
<global-content />
<global-footer />
</n-layout-content>
</n-layout>
</template>
<script setup lang="ts">
import { NLayout, NLayoutContent, NLayoutHeader } from 'naive-ui';
import { useThemeStore } from '@/store';
import { useLayoutConfig } from '@/composables';
import { MixSider, GlobalHeader, GlobalContent, GlobalFooter, GlobalTab, SpacePlaceholder } from '@/layouts/common';
const theme = useThemeStore();
const { headerInverted, headerPosition, globalSiderClassAndStyle, scrollbarContentStyle, scrollbar } =
useLayoutConfig();
</script>
<style scoped></style>

View File

@ -1,6 +0,0 @@
import VerticalLayout from './VerticalLayout/index.vue';
import VerticalMixLayout from './VerticalMixLayout/index.vue';
import HorizontalLayout from './HorizontalLayout/index.vue';
import HorizontalMixLayout from './HorizontalMixLayout/index.vue';
export { VerticalLayout, VerticalMixLayout, HorizontalLayout, HorizontalMixLayout };

View File

@ -1,26 +0,0 @@
<template>
<component :is="layoutComponent[theme.navStyle.mode]" />
<setting-drawer />
</template>
<script setup lang="ts">
import type { Component } from 'vue';
import { useThemeStore } from '@/store';
import type { NavMode } from '@/interface';
import { VerticalLayout, VerticalMixLayout, HorizontalLayout, HorizontalMixLayout } from './components';
import { SettingDrawer } from '../common';
type LayoutComponent = {
[key in NavMode]: Component;
};
const theme = useThemeStore();
const layoutComponent: LayoutComponent = {
vertical: VerticalLayout,
'vertical-mix': VerticalMixLayout,
horizontal: HorizontalLayout,
'horizontal-mix': HorizontalMixLayout
};
</script>
<style></style>

View File

@ -1,14 +0,0 @@
<template>
<n-scrollbar ref="scrollbar" class="h-full" :content-style="scrollbarContentStyle">
<global-content :show-padding="false" />
</n-scrollbar>
</template>
<script lang="ts" setup>
import { NScrollbar } from 'naive-ui';
import { useLayoutConfig } from '@/composables';
import { GlobalContent } from '../common';
const { scrollbarContentStyle } = useLayoutConfig();
</script>
<style scoped></style>

View File

@ -0,0 +1,11 @@
<template>
<div>
<h4>Layout</h4>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</div>
</template>
<script setup lang="ts"></script>
<style scoped></style>

View File

@ -1,34 +0,0 @@
<template>
<div
class="flex-1 flex-col-stretch bg-[#f6f9f8] dark:bg-deep-dark transition-all duration-300 ease-in-out"
:class="{ 'overflow-hidden': routeProps.fullPage, 'p-16px': showPadding }"
>
<router-view v-slot="{ Component, route }">
<transition :name="theme.pageAnimateType" mode="out-in" appear>
<keep-alive :include="cacheRoutes">
<component :is="Component" v-if="app.reloadFlag" :key="route.fullPath" class="flex-1" />
</keep-alive>
</transition>
</router-view>
</div>
</template>
<script setup lang="ts">
import { cacheRoutes } from '@/router';
import { useAppStore, useThemeStore } from '@/store';
import { useRouteProps } from '@/composables';
interface Props {
/** 显示padding */
showPadding?: boolean;
}
withDefaults(defineProps<Props>(), {
showPadding: true
});
const theme = useThemeStore();
const app = useAppStore();
const routeProps = useRouteProps();
</script>
<style scoped></style>

View File

@ -1,20 +0,0 @@
<template>
<n-layout-footer class="bg-white dark:bg-dark">
<div class="footer-height flex-center">Copyright ©2021 Soybean Admin</div>
</n-layout-footer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { NLayoutFooter } from 'naive-ui';
import { useThemeStore } from '@/store';
const theme = useThemeStore();
const footerHeight = computed(() => `${theme.footerStyle.height}px`);
</script>
<style scoped>
.footer-height {
height: v-bind(footerHeight);
}
</style>

View File

@ -1,14 +0,0 @@
<template>
<hover-container class="w-40px h-full" tooltip-content="全屏" @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 { HoverContainer } from '@/components';
const { isFullscreen, toggle } = useFullscreen();
</script>
<style scoped></style>

View File

@ -1,12 +0,0 @@
<template>
<hover-container tooltip-content="github" class="w-40px h-full">
<a href="https://github.com/honghuangdc/soybean-admin" target="_blank" class="flex-center">
<icon-mdi-github class="text-20px text-[#666]" />
</a>
</hover-container>
</template>
<script lang="ts" setup>
import { HoverContainer } from '@/components';
</script>
<style scoped></style>

View File

@ -1,91 +0,0 @@
<template>
<n-breadcrumb class="px-12px">
<template v-for="breadcrumb in breadcrumbList" :key="breadcrumb.key">
<n-breadcrumb-item>
<n-dropdown v-if="breadcrumb.hasChildren" :options="breadcrumb.children" @select="dropdownSelect">
<span>
<Icon
v-if="theme.crumbsStyle.showIcon && breadcrumb.iconName"
:icon="breadcrumb.iconName"
class="inline-block mr-4px text-16px"
/>
<span>{{ breadcrumb.label }}</span>
</span>
</n-dropdown>
<template v-else>
<Icon
v-if="theme.crumbsStyle.showIcon && breadcrumb.iconName"
:icon="breadcrumb.iconName"
class="inline-block mr-4px text-16px"
/>
<span>{{ breadcrumb.label }}</span>
</template>
</n-breadcrumb-item>
</template>
</n-breadcrumb>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { RouteLocationMatched } from 'vue-router';
import { NBreadcrumb, NBreadcrumbItem, NDropdown } from 'naive-ui';
import type { DropdownOption } from 'naive-ui';
import { Icon } from '@iconify/vue';
import { routePath } from '@/router';
import { useThemeStore } from '@/store';
import type { RouteKey } from '@/interface';
type Breadcrumb = DropdownOption & {
key: string;
label: string;
disabled: boolean;
routeName: RouteKey;
hasChildren: boolean;
iconName?: string;
children?: Breadcrumb[];
};
const theme = useThemeStore();
const route = useRoute();
const router = useRouter();
const breadcrumbList = computed<Breadcrumb[]>(() => generateBreadcrumb());
function generateBreadcrumb() {
const { matched } = route;
return recursionBreadcrumb(matched);
}
/** 递归匹配路由获取面包屑数据 */
function recursionBreadcrumb(routeMatched: RouteLocationMatched[]) {
const list: Breadcrumb[] = [];
routeMatched.forEach(item => {
if (!item.meta?.notAsMenu) {
const routeName = item.name as RouteKey;
const breadcrumItem: Breadcrumb = {
key: routeName,
label: (item.meta?.title as string) || '',
disabled: item.path === routePath('root'),
routeName,
hasChildren: false
};
if (item.meta?.icon) {
breadcrumItem.iconName = item.meta.icon as string;
}
if (item.children && item.children.length) {
breadcrumItem.hasChildren = true;
breadcrumItem.children = recursionBreadcrumb(item.children as RouteLocationMatched[]);
}
list.push(breadcrumItem);
}
});
return list;
}
function dropdownSelect(optionKey: string) {
const key = optionKey as RouteKey;
router.push({ name: key });
}
</script>
<style scoped></style>

View File

@ -1,27 +0,0 @@
<template>
<n-menu :value="activeKey" mode="horizontal" :options="menus" @update:value="handleUpdateMenu" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { MenuOption } from 'naive-ui';
import { NMenu } from 'naive-ui';
import { menus } from '@/router';
import { GlobalMenuOption } from '@/interface';
const router = useRouter();
const route = useRoute();
const activeKey = computed(() => getActiveKey());
function getActiveKey() {
return route.name as string;
}
function handleUpdateMenu(key: string, item: MenuOption) {
const menuItem = item as GlobalMenuOption;
router.push(menuItem.routePath);
}
</script>
<style scoped></style>

View File

@ -1,15 +0,0 @@
<template>
<hover-container class="w-40px h-full" @click="toggleMenu">
<icon-line-md-menu-unfold-left v-if="app.menu.collapsed" class="text-16px" />
<icon-line-md-menu-fold-left v-else class="text-16px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store';
import { HoverContainer } from '@/components';
const app = useAppStore();
const { toggleMenu } = useAppStore();
</script>
<style scoped></style>

View File

@ -1,13 +0,0 @@
<template>
<hover-container class="w-40px h-full" tooltip-content="项目配置" placement="bottom-end" @click="openSettingDrawer">
<icon-mdi-light-cog class="text-16px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store';
import { HoverContainer } from '@/components';
const { openSettingDrawer } = useAppStore();
</script>
<style scoped></style>

View File

@ -1,15 +0,0 @@
<template>
<hover-container class="w-40px" content-class="hover:text-primary" tooltip-content="主题模式" @click="toggleDarkMode">
<icon-mdi-moon-waning-crescent v-if="theme.darkMode" class="text-18px" />
<icon-mdi-white-balance-sunny v-else class="text-18px" />
</hover-container>
</template>
<script lang="ts" setup>
import { HoverContainer } from '@/components';
import { useThemeStore } from '@/store';
const theme = useThemeStore();
const { toggleDarkMode } = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,62 +0,0 @@
<template>
<n-dropdown :options="options" @select="handleDropdown">
<hover-container class="px-12px">
<img :src="avatar" class="w-32px h-32px" />
<span class="pl-8px text-16px font-medium">{{ auth.userInfo.userName }}</span>
</hover-container>
</n-dropdown>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { NDropdown, useDialog } from 'naive-ui';
import { HoverContainer } from '@/components';
import { useAuthStore } from '@/store';
import { useRouterPush } from '@/composables';
import { iconifyRender } from '@/utils';
import avatar from '@/assets/svg/avatar/avatar01.svg';
type DropdownKey = 'user-center' | 'logout';
const route = useRoute();
const auth = useAuthStore();
const { resetAuthState } = useAuthStore();
const { toLogin } = useRouterPush();
const dialog = useDialog();
const options = [
{
label: '用户中心',
key: 'user-center',
icon: iconifyRender('carbon:user-avatar')
},
{
type: 'divider',
key: 'divider'
},
{
label: '退出登录',
key: 'logout',
icon: iconifyRender('carbon:logout')
}
];
function handleDropdown(optionKey: string) {
const key = optionKey as DropdownKey;
if (key === 'logout') {
dialog.info({
title: '提示',
content: '您确定要退出登录吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
resetAuthState();
if (route.meta.requiresAuth) {
toLogin();
}
}
});
}
}
</script>
<style scoped></style>

View File

@ -1,19 +0,0 @@
import HeaderMenu from './HeaderMenu.vue';
import GlobalBreadcrumb from './GlobalBreadcrumb.vue';
import UserAvatar from './UserAvatar.vue';
import MenuCollapse from './MenuCollapse.vue';
import ThemeMode from './ThemeMode.vue';
import FullScreen from './FullScreen.vue';
import SettingDrawerButton from './SettingDrawerButton.vue';
import GithubSite from './GithubSite.vue';
export {
HeaderMenu,
GlobalBreadcrumb,
UserAvatar,
MenuCollapse,
ThemeMode,
FullScreen,
SettingDrawerButton,
GithubSite
};

View File

@ -1,58 +0,0 @@
<template>
<div class="global-header flex-y-center w-full" :style="{ height: headerHeight }">
<global-logo v-if="showLogo" :show-title="true" class="h-full" :style="{ width: theme.menuStyle.width + 'px' }" />
<div v-if="!showMenu" class="flex-1-hidden flex-y-center h-full">
<menu-collapse v-if="showMenuCollape" />
<global-breadcrumb v-if="theme.crumbsStyle.visible" />
</div>
<div
v-else
class="flex-1-hidden flex-y-center h-full"
:style="{ justifyContent: theme.menuStyle.horizontalPosition }"
>
<header-menu />
</div>
<div class="flex justify-end h-full">
<global-search />
<github-site />
<full-screen />
<theme-mode />
<user-avatar />
</div>
</div>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
import { useLayoutConfig } from '@/composables';
import {
HeaderMenu,
GlobalBreadcrumb,
UserAvatar,
MenuCollapse,
ThemeMode,
FullScreen,
GithubSite
} from './components';
import GlobalLogo from '../GlobalLogo/index.vue';
import GlobalSearch from '../GlobalSearch/index.vue';
interface Props {
/** 显示logo */
showLogo: boolean;
/** 显示菜单折叠按钮 */
showMenuCollape: boolean;
/** 显示菜单 */
showMenu: boolean;
}
defineProps<Props>();
const theme = useThemeStore();
const { headerHeight } = useLayoutConfig();
</script>
<style scoped>
.global-header {
box-shadow: 0 1px 2px rgb(0 21 41 / 8%);
}
</style>

View File

@ -1,24 +0,0 @@
<template>
<a href="/" class="flex-center w-full nowrap-hidden cursor-pointer" :style="{ height: headerHeight }">
<system-logo class="w-32px h-32px" :color="theme.themeColor" />
<h2 v-show="showTitle" class="text-primary pl-8px text-16px font-bold">{{ title }}</h2>
</a>
</template>
<script lang="ts" setup>
import { SystemLogo } from '@/components';
import { useThemeStore } from '@/store';
import { useAppInfo, useLayoutConfig } from '@/composables';
interface Props {
/** 显示名字 */
showTitle: boolean;
}
defineProps<Props>();
const theme = useThemeStore();
const { title } = useAppInfo();
const { headerHeight } = useLayoutConfig();
</script>
<style scoped></style>

View File

@ -1,71 +0,0 @@
<template>
<div>
<n-menu
:value="activeKey"
:collapsed="app.menu.collapsed"
:collapsed-width="theme.menuStyle.collapsedWidth"
:collapsed-icon-size="22"
:options="menus"
:expanded-keys="expandedKeys"
:indent="18"
@update:value="handleUpdateMenu"
@update:expanded-keys="handleUpdateExpandedKeys"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NMenu } from 'naive-ui';
import type { MenuOption } from 'naive-ui';
import { useThemeStore, useAppStore } from '@/store';
import { menus } from '@/router';
import { GlobalMenuOption } from '@/interface';
import { isUrl } from '@/utils';
const theme = useThemeStore();
const app = useAppStore();
const router = useRouter();
const route = useRoute();
const activeKey = computed(() => route.name as string);
const expandedKeys = ref<string[]>(getExpendedKeys());
function getExpendedKeys() {
const keys = menus.map(menu => getActiveKeysInMenus(menu)).flat();
return keys;
}
function getActiveKeysInMenus(menu: GlobalMenuOption) {
const keys: string[] = [];
if (activeKey.value.includes(menu.routeName)) {
keys.push(menu.routeName);
}
if (menu.children) {
keys.push(...menu.children.map(item => getActiveKeysInMenus(item as GlobalMenuOption)).flat());
}
return keys;
}
function handleUpdateMenu(_key: string, item: MenuOption) {
const menuItem = item as GlobalMenuOption;
if (isUrl(menuItem.routePath)) {
window.open(menuItem.routePath, '__blank');
} else {
router.push(menuItem.routePath);
}
}
function handleUpdateExpandedKeys(keys: string[]) {
expandedKeys.value = keys;
}
watch(
() => route.name,
() => {
expandedKeys.value = getExpendedKeys();
}
);
</script>
<style scoped></style>

View File

@ -1,24 +0,0 @@
<template>
<div class="px-24px h-44px flex-y-center">
<span class="mr-14px">
<icon-ant-design:enter-outlined class="icon text-20px p-2px mr-3px" />
确认
</span>
<span class="mr-14px">
<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-3px" />
切换
</span>
<span>
<icon-mdi:close class="icon text-20px p-2px mr-3px" />
关闭
</span>
</div>
</template>
<script lang="ts" setup></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,132 +0,0 @@
<template>
<n-modal
v-model:show="show"
:segmented="{ footer: 'soft' }"
:closable="false"
preset="card"
footer-style="padding: 0; margin: 0"
class="w-630px fixed top-50px left-1/2 transform -translate-x-1/2"
>
<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>
<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 />
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { ref, shallowRef, computed, watch, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import { NModal, NInput, NEmpty } from 'naive-ui';
import { useDebounceFn, onKeyStroke } from '@vueuse/core';
import { menusList } from '@/router';
import { isUrl } from '@/utils';
import SearchResult from './SearchResult.vue';
import SearchFooter from './SearchFooter.vue';
interface Props {
/** 弹窗显隐 */
value: boolean;
}
interface Emits {
(e: 'update:value', val: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
const router = useRouter();
const keyword = ref('');
const activePath = ref('');
const resultOptions = shallowRef<RouteRecordRaw[]>([]);
const inputRef = ref<HTMLInputElement | null>(null);
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 = menusList.filter(
menu => keyword.value && menu.meta?.title.toLocaleLowerCase().includes(keyword.value.toLocaleLowerCase().trim())
);
if (resultOptions.value?.length > 0) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = '';
}
}
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() {
if (isUrl(activePath.value)) {
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,62 +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)"
>
<Icon :icon="item.meta?.icon ?? 'mdi:bookmark-minus-outline'" />
<span class="flex-1 ml-5px">{{ 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 type { RouteRecordRaw } from 'vue-router';
import { NScrollbar } from 'naive-ui';
import { Icon } from '@iconify/vue';
import { useThemeStore } from '@/store';
interface Props {
value: string;
options: RouteRecordRaw[];
}
interface Emits {
(e: 'update:value', val: string): void;
(e: 'enter'): void;
}
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
const active = computed({
get() {
return props.value;
},
set(val: string) {
emit('update:value', val);
}
});
const theme = useThemeStore();
/** 鼠标移入 */
async function handleMouse(item: RouteRecordRaw) {
active.value = item.path;
}
function handleTo() {
emit('enter');
}
</script>
<style lang="scss" scoped></style>

View File

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

View File

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

View File

@ -1,140 +0,0 @@
<template>
<n-dropdown
:show="dropdownVisible"
:options="options"
placement="bottom-start"
:x="x"
:y="y"
@clickoutside="hide"
@select="handleDropdown"
/>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { NDropdown } from 'naive-ui';
import type { DropdownOption } from 'naive-ui';
import { useAppStore } from '@/store';
import { useBoolean } from '@/hooks';
import { ROUTE_HOME } from '@/router';
import { iconifyRender } from '@/utils';
interface Props {
/** 右键菜单可见性 */
visible?: boolean;
/** 当前是否是路由首页 */
isRouteHome?: boolean;
/** 当前路由路径 */
currentPath?: string;
/** 鼠标x坐标 */
x: number;
/** 鼠标y坐标 */
y: number;
}
type DropdownKey = 'reload-current' | 'close-current' | 'close-other' | 'close-left' | 'close-right' | 'close-all';
type Option = DropdownOption & {
key: DropdownKey;
};
const props = withDefaults(defineProps<Props>(), {
visible: false,
isRouteHome: false,
currentPath: ''
});
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const app = useAppStore();
const { handleReload, removeMultiTab, clearMultiTab, clearLeftMultiTab, clearRightMultiTab } = useAppStore();
const { bool: dropdownVisible, setTrue: show, setFalse: hide } = useBoolean(props.visible);
const options = computed<Option[]>(() => [
{
label: '重新加载',
key: 'reload-current',
disabled: props.currentPath !== app.multiTab.activeRoute,
icon: iconifyRender('ant-design:reload-outlined')
},
{
label: '关闭',
key: 'close-current',
disabled: props.currentPath === ROUTE_HOME.path,
icon: iconifyRender('ant-design:close-outlined')
},
{
label: '关闭其他',
key: 'close-other',
icon: iconifyRender('ant-design:column-width-outlined')
},
{
label: '关闭左侧',
key: 'close-left',
icon: iconifyRender('mdi:format-horizontal-align-left')
},
{
label: '关闭右侧',
key: 'close-right',
icon: iconifyRender('mdi:format-horizontal-align-right')
}
]);
const actionMap = new Map<DropdownKey, () => void>([
[
'reload-current',
() => {
handleReload();
}
],
[
'close-current',
() => {
removeMultiTab(props.currentPath);
}
],
[
'close-other',
() => {
clearMultiTab([props.currentPath]);
}
],
[
'close-left',
() => {
clearLeftMultiTab(props.currentPath);
}
],
[
'close-right',
() => {
clearRightMultiTab(props.currentPath);
}
]
]);
function handleDropdown(optionKey: string) {
const key = optionKey as DropdownKey;
const actionFunc = actionMap.get(key);
if (actionFunc) {
actionFunc();
}
hide();
}
watch(
() => props.visible,
newValue => {
if (newValue) {
show();
} else {
hide();
}
}
);
watch(dropdownVisible, newValue => {
emit('update:visible', newValue);
});
</script>
<style scoped></style>

View File

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

View File

@ -1,113 +0,0 @@
<template>
<div v-if="theme.multiTabStyle.mode === 'chrome'" ref="tabRef" class="flex items-end h-full">
<chrome-tab
v-for="(item, index) in app.multiTab.routes"
:key="item.path"
:is-active="app.multiTab.activeRoute === item.fullPath"
:primary-color="theme.themeColor"
:closable="item.name !== ROUTE_HOME.name"
:dark-mode="theme.darkMode"
:is-last="index === app.multiTab.routes.length - 1"
@click="handleClickTab(item.fullPath)"
@close="removeMultiTab(item.fullPath)"
@contextmenu="handleContextMenu($event, item.fullPath)"
>
{{ item.meta?.title }}
</chrome-tab>
</div>
<div v-if="theme.multiTabStyle.mode === 'button'" ref="tabRef" class="flex-y-center h-full">
<button-tab
v-for="item in app.multiTab.routes"
:key="item.path"
class="mr-10px"
:is-active="app.multiTab.activeRoute === item.fullPath"
:primary-color="theme.themeColor"
:closable="item.name !== ROUTE_HOME.name"
:dark-mode="theme.darkMode"
@click="handleClickTab(item.fullPath)"
@close="removeMultiTab(item.fullPath)"
@contextmenu="handleContextMenu($event, item.fullPath)"
>
{{ item.meta?.title }}
</button-tab>
</div>
<context-menu
:visible="dropdownVisible"
:current-path="dropdownConfig.currentPath"
:x="dropdownConfig.x"
:y="dropdownConfig.y"
/>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useThemeStore, useAppStore } from '@/store';
import { ROUTE_HOME } from '@/router';
import { ChromeTab, ButtonTab } from '@/components';
import { useBoolean } from '@/hooks';
import { setTabRouteStorage } from '@/utils';
import { ContextMenu } from './components';
interface Emits {
(e: 'scroll', clientX: number): void;
}
const emit = defineEmits<Emits>();
const theme = useThemeStore();
const app = useAppStore();
const { removeMultiTab, handleClickTab } = useAppStore();
const { bool: dropdownVisible, setTrue: showDropdown, setFalse: hideDropdown } = useBoolean();
const dropdownConfig = reactive({
x: 0,
y: 0,
currentPath: ''
});
function setDropdownConfig(x: number, y: number, currentPath: string) {
Object.assign(dropdownConfig, { x, y, currentPath });
}
// 获取当前激活的tab的clientX
const tabRef = ref<HTMLElement | null>(null);
async function getActiveTabClientX() {
await nextTick();
const index = app.activeMultiTabIndex;
if (tabRef.value) {
const activeTabElement = tabRef.value.children[index];
const { x, width } = activeTabElement.getBoundingClientRect();
const clientX = x + width / 2;
setTimeout(() => {
emit('scroll', clientX);
}, 50);
}
}
// 右键菜单
function handleContextMenu(e: MouseEvent, fullPath: string) {
e.preventDefault();
const { clientX, clientY } = e;
hideDropdown();
setDropdownConfig(clientX, clientY, fullPath);
nextTick(() => {
showDropdown();
});
}
/** 页面离开时缓存多页签数据 */
useEventListener(window, 'beforeunload', () => {
setTabRouteStorage(app.multiTab.routes);
});
watch(
() => app.activeMultiTabIndex,
() => {
getActiveTabClientX();
},
{
immediate: true
}
);
</script>
<style scoped></style>

View File

@ -1,35 +0,0 @@
<template>
<hover-container class="w-64px h-full" tooltip-content="重新加载" placement="bottom-end" @click="handleRefresh">
<icon-mdi-refresh class="text-18px" :class="{ 'reload-animation': loading }" />
</hover-container>
</template>
<script lang="ts" setup>
import { HoverContainer } from '@/components';
import { useAppStore } from '@/store';
import { useLoading } from '@/hooks';
const { handleReload } = useAppStore();
const { loading, startLoading, endLoading } = useLoading();
function handleRefresh() {
startLoading();
handleReload();
setTimeout(() => {
endLoading();
}, 1000);
}
</script>
<style scoped>
.reload-animation {
animation: rotate 1s;
}
@keyframes rotate {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,4 +0,0 @@
import MultiTab from './MultiTab/index.vue';
import ReloadButton from './ReloadButton/index.vue';
export { MultiTab, ReloadButton };

View File

@ -1,59 +0,0 @@
<template>
<div class="multi-tab flex-center w-full pl-16px" :style="{ height: multiTabHeight }">
<div ref="bsWrapperRef" class="flex-1-hidden h-full">
<better-scroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: isMobile }">
<multi-tab @scroll="handleScroll" />
</better-scroll>
</div>
<reload-button />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { useElementBounding } from '@vueuse/core';
import { useAppStore } from '@/store';
import { useLayoutConfig, routeFullPathWatcher, useIsMobile } from '@/composables';
import { BetterScroll } from '@/components';
import type { ExposeBetterScroll } from '@/interface';
import { MultiTab, ReloadButton } from './components';
const route = useRoute();
const { initMultiTab, addMultiTab, setActiveMultiTab } = useAppStore();
const { multiTabHeight } = useLayoutConfig();
const isMobile = useIsMobile();
const bsWrapperRef = ref<HTMLElement | null>(null);
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapperRef);
const bsScroll = ref<ExposeBetterScroll | null>(null);
function handleScroll(clientX: number) {
const currentX = clientX - bsWrapperLeft.value;
const deltaX = currentX - bsWrapperWidth.value / 2;
if (bsScroll.value) {
const { maxScrollX, x: leftX } = bsScroll.value.bsInstance;
const rightX = maxScrollX - leftX;
const update = deltaX > 0 ? Math.max(-deltaX, rightX) : Math.min(-deltaX, -leftX);
bsScroll.value?.bsInstance.scrollBy(update, 0, 300);
}
}
function init() {
initMultiTab();
}
routeFullPathWatcher(fullPath => {
addMultiTab(route);
setActiveMultiTab(fullPath);
});
// 初始化
init();
</script>
<style scoped>
.multi-tab {
box-shadow: 0 1px 2px rgb(0 21 41 / 8%);
}
</style>

View File

@ -1,21 +0,0 @@
<template>
<div v-if="theme.fixedHeaderAndTab" :style="{ height: headerAndMultiTabHeight }"></div>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/store';
import { useLayoutConfig } from '@/composables';
interface Props {
/** 去除tab的高度 */
removeTab?: boolean;
}
withDefaults(defineProps<Props>(), {
removeTab: false
});
const theme = useThemeStore();
const { headerAndMultiTabHeight } = useLayoutConfig();
</script>
<style scoped></style>

View File

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

View File

@ -1,14 +0,0 @@
<template>
<div class="flex-center h-36px text-[#333639] dark:text-[#ffffffd1] cursor-pointer" @click="toggleMenu">
<icon-ph-caret-double-right-bold v-if="app.menu.collapsed" class="text-16px" />
<icon-ph-caret-double-left-bold v-else class="text-16px" />
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store';
const app = useAppStore();
const { toggleMenu } = useAppStore();
</script>
<style scoped></style>

View File

@ -1,82 +0,0 @@
<template>
<div
class="drawer-shadow absolute-lt flex-col-stretch h-full overflow-hidden bg-white dark:bg-dark transition-width duration-300 ease-in-out"
:style="{ width: showDrawer ? theme.menuStyle.width + 'px' : '0px' }"
>
<header class="header-height flex-y-center justify-between">
<h2 class="text-primary pl-8px text-16px font-bold">{{ title }}</h2>
<div class="px-8px text-16px text-gray-600 cursor-pointer" @click="toggleFixedMixMenu">
<icon-mdi-pin-off v-if="app.menu.fixedMix" />
<icon-mdi-pin v-else />
</div>
</header>
<div class="flex-1-hidden">
<n-scrollbar>
<n-menu :value="activeKey" :options="childMenus" :indent="18" @update:value="handleUpdateMenu" />
</n-scrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { NScrollbar, NMenu } from 'naive-ui';
import type { MenuOption } from 'naive-ui';
import { useThemeStore, useAppStore } from '@/store';
import { useAppInfo } from '@/composables';
import { menus } from '@/router';
import type { GlobalMenuOption } from '@/interface';
interface Props {
/** 菜单抽屉可见性 */
visible?: boolean;
/** 激活状态的路由名称 */
activeRouteName: string;
}
const props = withDefaults(defineProps<Props>(), {
visible: false
});
const router = useRouter();
const route = useRoute();
const theme = useThemeStore();
const app = useAppStore();
const { toggleFixedMixMenu } = useAppStore();
const { title } = useAppInfo();
const childMenus = computed(() => {
const children: MenuOption[] = [];
menus.some(item => {
const flag = item.routeName === props.activeRouteName && Boolean(item.children?.length);
if (flag) {
children.push(...item.children!);
}
return flag;
});
return children;
});
const showDrawer = computed(() => (props.visible && childMenus.value.length) || app.menu.fixedMix);
const activeKey = computed(() => route.name as string);
function handleUpdateMenu(key: string, item: MenuOption) {
const menuItem = item as GlobalMenuOption;
router.push(menuItem.routePath);
}
const headerHeight = computed(() => {
const { height } = theme.headerStyle;
return `${height}px`;
});
</script>
<style scoped>
.drawer-shadow {
box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
}
.header-height {
height: v-bind(headerHeight);
}
</style>

View File

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

View File

@ -1,104 +0,0 @@
<template>
<div class="flex h-full bg-white dark:bg-[#18181c]" @mouseleave="handleMouseLeaveMenu">
<div
class="flex-col-stretch flex-1 h-full transition-width duration-300 ease-in-out"
:class="[app.menu.collapsed ? 'mix-menu_collapsed-width' : 'mix-menu_width']"
>
<global-logo :show-title="false" />
<div class="flex-1-hidden">
<n-scrollbar>
<mix-menu
v-for="item in firstDegreeMenus"
:key="item.routeName"
:route-name="item.routeName"
:label="item.label"
:icon="item.icon"
:active-route-name="activeParentRouteName"
:is-mini="app.menu.collapsed"
@click="handleMixMenu(item.routeName, item.isSingle)"
/>
</n-scrollbar>
</div>
<mix-menu-collapse />
</div>
<div
class="relative h-full transition-width duration-300 ease-in-out"
:style="{ width: app.menu.fixedMix ? theme.menuStyle.width + 'px' : '0px' }"
>
<mix-menu-drawer :visible="drawerVisible" :active-route-name="activeParentRouteName" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { VNodeChild } from 'vue';
import { NScrollbar } from 'naive-ui';
import { useRouter, useRoute } from 'vue-router';
import { useAppStore, useThemeStore } from '@/store';
import { menus } from '@/router';
import { routeNameWatcher } from '@/composables';
import { useBoolean } from '@/hooks';
import { MixMenu, MixMenuCollapse, MixMenuDrawer } from './components';
import GlobalLogo from '../GlobalLogo/index.vue';
const theme = useThemeStore();
const app = useAppStore();
const router = useRouter();
const route = useRoute();
const { bool: drawerVisible, setTrue: openDrawer, setFalse: hideDrawer } = useBoolean();
const mixMenuWidth = computed(() => `${theme.menuStyle.mixWidth}px`);
const mixMenuCollapsedWidth = computed(() => `${theme.menuStyle.mixCollapsedWidth}px`);
const firstDegreeMenus = menus.map(item => {
const { routeName } = item;
const label = item.label as string;
const icon = item.icon! as () => VNodeChild;
const isSingle = !(item.children && item.children.length);
return {
routeName,
label,
icon,
isSingle
};
});
const activeParentRouteName = ref(getActiveRouteName());
function getActiveRouteName() {
let name = '';
const menuMatched = route.matched.filter(item => !item.meta?.notAsMenu);
if (menuMatched.length) {
name = menuMatched[0].name as string;
}
return name;
}
function handleMixMenu(routeName: string, isSingle: boolean) {
activeParentRouteName.value = routeName;
if (isSingle) {
router.push({ name: routeName });
} else {
openDrawer();
}
}
function handleMouseLeaveMenu() {
activeParentRouteName.value = getActiveRouteName();
hideDrawer();
}
routeNameWatcher(() => {
activeParentRouteName.value = getActiveRouteName();
});
</script>
<style scoped>
.mix-menu_width {
width: v-bind(mixMenuWidth);
}
.mix-menu_collapsed-width {
width: v-bind(mixMenuCollapsedWidth);
}
</style>

View File

@ -1,26 +0,0 @@
<template>
<n-divider title-placement="center">深色主题</n-divider>
<div class="flex-center">
<n-switch :value="theme.darkMode" @update:value="handleDarkMode">
<template #checked>
<icon-mdi-white-balance-sunny class="text-14px text-primary" />
</template>
<template #unchecked>
<icon-mdi-moon-waning-crescent class="text-14px text-primary" />
</template>
</n-switch>
</div>
</template>
<script lang="ts" setup>
import { NDivider, NSwitch } from 'naive-ui';
import { useThemeStore } from '@/store';
const theme = useThemeStore();
const { handleDarkMode } = useThemeStore();
</script>
<style scoped>
:deep(.n-switch__rail) {
background-color: #000e1c !important;
}
</style>

View File

@ -1,26 +0,0 @@
<template>
<div
class="fixed flex-center top-240px right-14px z-10000 w-48px h-48px bg-primary rounded-4px cursor-pointer transition-right duration-300 ease-in-out"
:class="{ '!right-330px': app.settingDrawer.visible }"
@click="handleClickButton"
>
<icon-ic:round-close v-if="app.settingDrawer.visible" class="z-20 text-24px text-white" />
<icon-ic-round-settings v-else class="z-20 text-24px text-white" />
</div>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store';
const app = useAppStore();
const { openSettingDrawer, closeSettingDrawer } = useAppStore();
function handleClickButton() {
if (app.settingDrawer.visible) {
closeSettingDrawer();
} else {
openSettingDrawer();
}
}
</script>
<style scoped></style>

View File

@ -1,59 +0,0 @@
<template>
<div
class="nav-type border-2px rounded-6px cursor-pointer"
:class="[checked ? 'border-primary' : 'border-transparent']"
>
<n-tooltip :placement="activeConfig.placement" trigger="hover">
<template #trigger>
<div class="nav-type-main relative w-56px h-48px bg-[#fff] 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>{{ EnumNavMode[mode] }}</span>
</n-tooltip>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { NTooltip } from 'naive-ui';
import type { FollowerPlacement } from 'vueuc';
import { EnumNavMode } from '@/enum';
import type { NavMode } from '@/interface';
interface Props {
/** 导航模式 */
mode?: NavMode;
/** 选中状态 */
checked?: boolean;
/** 主题颜色 */
primaryColor?: string;
}
const props = withDefaults(defineProps<Props>(), {
mode: 'vertical',
checked: false,
primaryColor: '#409EFF'
});
const config = new Map<NavMode, { placement: FollowerPlacement; menuClass: string; mainClass: string }>([
['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(() => config.get(props.mode)!);
</script>
<style scoped>
.border-primary {
border-color: v-bind(primaryColor);
}
.nav-type:hover {
border-color: v-bind(primaryColor);
}
.nav-type-main {
box-shadow: 0 1px 2.5px rgba(0, 0, 0, 0.18);
}
</style>

View File

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

View File

@ -1,37 +0,0 @@
<template>
<n-divider title-placement="center">导航栏模式</n-divider>
<n-space justify="space-between">
<nav-type
v-for="item in modeList"
:key="item.mode"
:mode="item.mode"
:checked="theme.navStyle.mode === item.mode"
:primary-color="theme.themeColor"
@click="setNavMode(item.mode)"
/>
</n-space>
</template>
<script lang="ts" setup>
import { NDivider, NSpace } from 'naive-ui';
import { EnumNavMode } from '@/enum';
import type { NavMode } from '@/interface';
import { NavType } from './components';
import { useThemeStore } from '@/store';
interface ModeList {
mode: NavMode;
label: string;
}
const theme = useThemeStore();
const { setNavMode } = useThemeStore();
const modeList: ModeList[] = [
{ mode: 'vertical', label: EnumNavMode.vertical },
{ mode: 'vertical-mix', label: EnumNavMode['vertical-mix'] },
{ mode: 'horizontal', label: EnumNavMode.horizontal },
{ mode: 'horizontal-mix', label: EnumNavMode['horizontal-mix'] }
];
</script>
<style scoped></style>

View File

@ -1,102 +0,0 @@
<template>
<n-divider title-placement="center">界面功能</n-divider>
<n-space vertical size="large">
<setting-menu-item label="顶部菜单位置">
<n-select
class="w-120px"
size="small"
:value="theme.menuStyle.horizontalPosition"
:options="theme.menuStyle.horizontalPositionList"
@update:value="handleHorizontalMenuPosition"
/>
</setting-menu-item>
<setting-menu-item label="菜单展开宽度">
<n-input-number
class="w-120px"
size="small"
:value="theme.menuStyle.width"
:disabled="disabledMenuWidth"
:step="10"
@update:value="handleMenuWidth"
/>
</setting-menu-item>
<setting-menu-item label="左侧混合菜单展开宽度">
<n-input-number
class="w-120px"
size="small"
:value="theme.menuStyle.mixWidth"
:disabled="disabledMixMenuWidth"
:step="5"
@update:value="handleMixMenuWidth"
/>
</setting-menu-item>
<setting-menu-item label="固定头部和多页签">
<n-switch :value="theme.fixedHeaderAndTab" :disabled="isHorizontalMix" @update:value="handleFixedHeaderAndTab" />
</setting-menu-item>
<setting-menu-item label="头部高度">
<n-input-number
class="w-120px"
size="small"
:value="theme.headerStyle.height"
:step="1"
@update:value="handleHeaderHeight"
/>
</setting-menu-item>
<setting-menu-item label="多页签高度">
<n-input-number
class="w-120px"
size="small"
:value="theme.multiTabStyle.height"
:step="1"
@update:value="handleMultiTabHeight"
/>
</setting-menu-item>
<setting-menu-item label="多页签缓存">
<n-switch :value="theme.multiTabStyle.isCache" @update:value="handleSetMultiTabCache" />
</setting-menu-item>
<setting-menu-item label="清空多页签缓存">
<n-popconfirm placement="top-end" @positive-click="handleRemoveTabRouteCache">
<template #trigger>
<n-button type="primary" size="small">清空</n-button>
</template>
确定要清空多页签缓存吗
</n-popconfirm>
</setting-menu-item>
</n-space>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { NDivider, NSpace, NSwitch, NSelect, NInputNumber, NButton, NPopconfirm, useMessage } from 'naive-ui';
import { useThemeStore, useAppStore } from '@/store';
import { clearTabRoutes } from '@/utils';
import { SettingMenuItem } from '../common';
const theme = useThemeStore();
const {
handleHorizontalMenuPosition,
handleFixedHeaderAndTab,
handleHeaderHeight,
handleMultiTabHeight,
handleMenuWidth,
handleMixMenuWidth,
handleSetMultiTabCache
} = useThemeStore();
const { initMultiTab } = useAppStore();
const message = useMessage();
const isHorizontalMix = computed(() => theme.navStyle.mode === 'horizontal-mix');
const disabledMenuWidth = computed(() => {
const { mode } = theme.navStyle;
return mode !== 'vertical' && mode !== 'horizontal-mix';
});
const disabledMixMenuWidth = computed(() => theme.navStyle.mode !== 'vertical-mix');
function handleRemoveTabRouteCache() {
clearTabRoutes();
initMultiTab();
message.success('操作成功!');
}
</script>
<style scoped></style>

View File

@ -1,52 +0,0 @@
<template>
<n-divider title-placement="center">界面显示</n-divider>
<n-space vertical size="large">
<setting-menu-item label="面包屑">
<n-switch :value="theme.crumbsStyle.visible" @update:value="handleCrumbsVisible" />
</setting-menu-item>
<setting-menu-item label="面包屑图标">
<n-switch :value="theme.crumbsStyle.showIcon" @update:value="handleCrumbsIconVisible" />
</setting-menu-item>
<setting-menu-item label="多页签">
<n-switch :value="theme.multiTabStyle.visible" @update:value="handleMultiTabVisible" />
</setting-menu-item>
<setting-menu-item label="多页签风格">
<n-select
class="w-120px"
size="small"
:value="theme.multiTabStyle.mode"
:options="theme.multiTabStyle.modeList"
@update:value="handleMultiTabMode"
/>
</setting-menu-item>
<setting-menu-item label="页面切换动画">
<n-switch :value="theme.pageStyle.animate" @update:value="handlePageAnimate" />
</setting-menu-item>
<setting-menu-item label="页面切换动画类型">
<n-select
class="w-120px"
size="small"
:value="theme.pageStyle.animateType"
:options="theme.pageStyle.animateTypeList"
@update:value="handlePageAnimateType"
/>
</setting-menu-item>
</n-space>
</template>
<script lang="ts" setup>
import { NDivider, NSpace, NSwitch, NSelect } from 'naive-ui';
import { useThemeStore } from '@/store';
import { SettingMenuItem } from '../common';
const theme = useThemeStore();
const {
handleCrumbsVisible,
handleCrumbsIconVisible,
handleMultiTabVisible,
handleMultiTabMode,
handlePageAnimate,
handlePageAnimateType
} = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,18 +0,0 @@
<template>
<n-divider title-placement="center">系统主题</n-divider>
<n-grid :cols="8">
<n-grid-item v-for="color in theme.themeColorList" :key="color">
<color-block :color="color" :checked="color === theme.themeColor" @click="setThemeColor(color)" />
</n-grid-item>
</n-grid>
</template>
<script lang="ts" setup>
import { NDivider, NGrid, NGridItem } from 'naive-ui';
import { useThemeStore } from '@/store';
import { ColorBlock } from '../common';
const theme = useThemeStore();
const { setThemeColor } = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,65 +0,0 @@
<template>
<n-divider title-placement="center">主题配置</n-divider>
<n-space vertical>
<div ref="copyRef" :data-clipboard-text="dataClipboardText">
<n-button type="primary" :block="true">拷贝当前配置</n-button>
</div>
<n-button type="warning" :block="true" @click="handleResetConfig">重置当前配置</n-button>
</n-space>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { NDivider, NSpace, NButton, useDialog, useMessage } from 'naive-ui';
import Clipboard from 'clipboard';
import { useThemeStore } from '@/store';
const theme = useThemeStore();
const { setDefaultThemeStore } = useThemeStore();
const dialog = useDialog();
const message = useMessage();
const copyRef = ref<HTMLElement | null>(null);
const dataClipboardText = ref(getClipboardText());
function getClipboardText() {
return JSON.stringify(theme.$state);
}
function handleResetConfig() {
setDefaultThemeStore();
message.success('已重置配置,请重新拷贝!');
}
function clipboardEventListener() {
const copy = new Clipboard(copyRef.value!);
copy.on('success', () => {
dialog.success({
title: '操作成功',
content: '复制成功,请替换 src/settings/theme.json的内容',
positiveText: '确定'
});
});
}
watch(
() => theme.$state,
() => {
dataClipboardText.value = getClipboardText();
},
{ deep: true }
);
onMounted(() => {
clipboardEventListener();
});
// function handleSuccess() {
// window.$dialog?.success({
// title: '操作成功',
// content: '复制成功,请替换 src/settings/theme.json的内容',
// positiveText: '确定'
// });
// }
</script>
<style scoped></style>

View File

@ -1,26 +0,0 @@
<template>
<div class="flex-center w-20px h-20px mx-6px mb-8px cursor-pointer rounded-2px" :style="{ backgroundColor: color }">
<icon-ic-outline-check
v-if="checked"
class="text-14px text-white"
:class="[isWhite ? 'text-gray-700' : 'text-white']"
/>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
interface Props {
color: string;
checked?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
checked: false
});
const whiteColors = ['#ffffff', '#fff', 'rgb(255,255,255)'];
const isWhite = computed(() => whiteColors.includes(props.color));
</script>
<style scoped></style>

View File

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

View File

@ -1,4 +0,0 @@
import ColorBlock from './ColorBlock.vue';
import SettingMenuItem from './SettingMenuItem.vue';
export { ColorBlock, SettingMenuItem };

View File

@ -1,9 +0,0 @@
import DarkMode from './DarkMode/index.vue';
import NavMode from './NavMode/index.vue';
import SystemTheme from './SystemTheme/index.vue';
import PageFunc from './PageFunc/index.vue';
import PageView from './PageView/index.vue';
import ThemeConfig from './ThemeConfig/index.vue';
import DrawerButton from './DrawerButton/index.vue';
export { DarkMode, NavMode, SystemTheme, PageFunc, PageView, ThemeConfig, DrawerButton };

View File

@ -1,24 +0,0 @@
<template>
<n-drawer v-model:show="app.settingDrawer.visible" display-directive="show" :width="330">
<n-drawer-content title="主题配置" :native-scrollbar="false">
<dark-mode />
<nav-mode />
<system-theme />
<page-func />
<page-view />
<theme-config />
</n-drawer-content>
</n-drawer>
<drawer-button v-if="showSettingButton" />
</template>
<script lang="ts" setup>
import { NDrawer, NDrawerContent } from 'naive-ui';
import { useAppStore } from '@/store';
import { DarkMode, NavMode, SystemTheme, PageFunc, PageView, ThemeConfig, DrawerButton } from './components';
const app = useAppStore();
const showSettingButton = import.meta.env.DEV || import.meta.env.VITE_HTTP_ENV === 'STAGING';
</script>
<style scoped></style>

View File

@ -1,41 +0,0 @@
<template>
<div v-if="theme.fixedHeaderAndTab" class="space-placholder_height"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store';
interface Props {
/** 去除tab的高度 */
removeHeader?: boolean;
/** 去除tab的高度 */
removeTab?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
removeHeader: false,
removeTab: false
});
const theme = useThemeStore();
const spaceHeight = computed(() => {
const {
multiTabStyle: { visible, height: tabHeight },
headerStyle: { height: headerHeight }
} = theme;
let height = 0;
if (!props.removeHeader) {
height += headerHeight;
}
if (!props.removeTab && visible) {
height += tabHeight;
}
return `${height}px`;
});
</script>
<style scoped>
.space-placholder_height {
height: v-bind(spaceHeight);
}
</style>

View File

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

View File

@ -1,14 +0,0 @@
<template>
<div class="flex-center h-36px text-[#333639] dark:text-[#ffffffd1] cursor-pointer" @click="toggleMenu">
<icon-ph-caret-double-right-bold v-if="app.menu.collapsed" class="text-16px" />
<icon-ph-caret-double-left-bold v-else class="text-16px" />
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store';
const app = useAppStore();
const { toggleMenu } = useAppStore();
</script>
<style scoped></style>

View File

@ -1,82 +0,0 @@
<template>
<div
class="drawer-shadow absolute-lt flex-col-stretch h-full overflow-hidden bg-white dark:bg-dark transition-width duration-300 ease-in-out"
:style="{ width: showDrawer ? theme.menuStyle.width + 'px' : '0px' }"
>
<header class="header-height flex-y-center justify-between">
<h2 class="text-primary pl-8px text-16px font-bold">{{ title }}</h2>
<div class="px-8px text-16px text-gray-600 cursor-pointer" @click="toggleFixedMixMenu">
<icon-mdi-pin-off v-if="app.menu.fixedMix" />
<icon-mdi-pin v-else />
</div>
</header>
<div class="flex-1-hidden">
<n-scrollbar>
<n-menu :value="activeKey" :options="childMenus" :indent="18" @update:value="handleUpdateMenu" />
</n-scrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { NScrollbar, NMenu } from 'naive-ui';
import type { MenuOption } from 'naive-ui';
import { useThemeStore, useAppStore } from '@/store';
import { useAppInfo } from '@/composables';
import { menus } from '@/router';
import type { GlobalMenuOption } from '@/interface';
interface Props {
/** 菜单抽屉可见性 */
visible?: boolean;
/** 激活状态的路由名称 */
activeRouteName: string;
}
const props = withDefaults(defineProps<Props>(), {
visible: false
});
const router = useRouter();
const route = useRoute();
const theme = useThemeStore();
const app = useAppStore();
const { toggleFixedMixMenu } = useAppStore();
const { title } = useAppInfo();
const childMenus = computed(() => {
const children: MenuOption[] = [];
menus.some(item => {
const flag = item.routeName === props.activeRouteName && Boolean(item.children?.length);
if (flag) {
children.push(...item.children!);
}
return flag;
});
return children;
});
const showDrawer = computed(() => (props.visible && childMenus.value.length) || app.menu.fixedMix);
const activeKey = computed(() => route.name as string);
function handleUpdateMenu(key: string, item: MenuOption) {
const menuItem = item as GlobalMenuOption;
router.push(menuItem.routePath);
}
const headerHeight = computed(() => {
const { height } = theme.headerStyle;
return `${height}px`;
});
</script>
<style scoped>
.drawer-shadow {
box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
}
.header-height {
height: v-bind(headerHeight);
}
</style>

View File

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

View File

@ -1,104 +0,0 @@
<template>
<div class="flex h-full bg-white dark:bg-[#18181c]" @mouseleave="handleMouseLeaveMenu">
<div
class="flex-col-stretch flex-1 h-full transition-width duration-300 ease-in-out"
:class="[app.menu.collapsed ? 'mix-menu_collapsed-width' : 'mix-menu_width']"
>
<global-logo :show-title="false" />
<div class="flex-1-hidden">
<n-scrollbar>
<mix-menu
v-for="item in firstDegreeMenus"
:key="item.routeName"
:route-name="item.routeName"
:label="item.label"
:icon="item.icon"
:active-route-name="activeParentRouteName"
:is-mini="app.menu.collapsed"
@click="handleMixMenu(item.routeName, item.isSingle)"
/>
</n-scrollbar>
</div>
<mix-menu-collapse />
</div>
<div
class="relative h-full transition-width duration-300 ease-in-out"
:style="{ width: app.menu.fixedMix ? theme.menuStyle.width + 'px' : '0px' }"
>
<mix-menu-drawer :visible="drawerVisible" :active-route-name="activeParentRouteName" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { VNodeChild } from 'vue';
import { NScrollbar } from 'naive-ui';
import { useRouter, useRoute } from 'vue-router';
import { useAppStore, useThemeStore } from '@/store';
import { menus } from '@/router';
import { routeNameWatcher } from '@/composables';
import { useBoolean } from '@/hooks';
import { MixMenu, MixMenuCollapse, MixMenuDrawer } from './components';
import GlobalLogo from '../GlobalLogo/index.vue';
const theme = useThemeStore();
const app = useAppStore();
const router = useRouter();
const route = useRoute();
const { bool: drawerVisible, setTrue: openDrawer, setFalse: hideDrawer } = useBoolean();
const mixMenuWidth = computed(() => `${theme.menuStyle.mixWidth}px`);
const mixMenuCollapsedWidth = computed(() => `${theme.menuStyle.mixCollapsedWidth}px`);
const firstDegreeMenus = menus.map(item => {
const { routeName } = item;
const label = item.label as string;
const icon = item.icon! as () => VNodeChild;
const isSingle = !(item.children && item.children.length);
return {
routeName,
label,
icon,
isSingle
};
});
const activeParentRouteName = ref(getActiveRouteName());
function getActiveRouteName() {
let name = '';
const menuMatched = route.matched.filter(item => !item.meta?.notAsMenu);
if (menuMatched.length) {
name = menuMatched[0].name as string;
}
return name;
}
function handleMixMenu(routeName: string, isSingle: boolean) {
activeParentRouteName.value = routeName;
if (isSingle) {
router.push({ name: routeName });
} else {
openDrawer();
}
}
function handleMouseLeaveMenu() {
activeParentRouteName.value = getActiveRouteName();
hideDrawer();
}
routeNameWatcher(() => {
activeParentRouteName.value = getActiveRouteName();
});
</script>
<style scoped>
.mix-menu_width {
width: v-bind(mixMenuWidth);
}
.mix-menu_collapsed-width {
width: v-bind(mixMenuCollapsedWidth);
}
</style>

View File

@ -1,25 +0,0 @@
import GlobalHeader from './GlobalHeader/index.vue';
import GlobalContent from './GlobalContent/index.vue';
import GlobalFooter from './GlobalFooter/index.vue';
import GlobalLogo from './GlobalLogo/index.vue';
import GlobalMenu from './GlobalMenu/index.vue';
import GlobalTab from './GlobalTab/index.vue';
import VerticalMixSider from './VerticalMixSider/index.vue';
import MixSider from './MixSider/index.vue';
import SpacePlaceholder from './SpacePlaceholder/index.vue';
import HeaderPlaceholder from './HeaderPlaceholder/index.vue';
import SettingDrawer from './SettingDrawer/index.vue';
export {
GlobalHeader,
GlobalContent,
GlobalFooter,
GlobalLogo,
GlobalMenu,
GlobalTab,
VerticalMixSider,
MixSider,
SpacePlaceholder,
HeaderPlaceholder,
SettingDrawer
};

View File

@ -1,4 +1,3 @@
import BasicLayout from './BasicLayout/index.vue';
import BlankLayout from './BlankLayout/index.vue';
import Layout from './Layout/index.vue';
export { BasicLayout, BlankLayout };
export { Layout };