halo/console/console-src/layouts/BasicLayout.vue

460 lines
13 KiB
Vue

<script lang="ts" setup>
import {
IconMore,
IconSearch,
IconUserSettings,
VTag,
VAvatar,
Dialog,
VDropdown,
VDropdownItem,
} from "@halo-dev/components";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import {
RouterView,
useRoute,
useRouter,
type RouteRecordRaw,
} from "vue-router";
import { onMounted, onUnmounted, reactive, ref } from "vue";
import axios from "axios";
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
import LoginModal from "@/components/login/LoginModal.vue";
import { coreMenuGroups } from "@console/router/routes.config";
import sortBy from "lodash.sortby";
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
import { useUserStore } from "@/stores/user";
import { rbacAnnotations } from "@/constants/annotations";
import { defineStore, storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import { isMac } from "@/utils/device";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const moreMenuVisible = ref(false);
const moreMenuRootVisible = ref(false);
const userStore = useUserStore();
const { currentRoles, currentUser } = storeToRefs(userStore);
const handleLogout = () => {
Dialog.warning({
title: t("core.sidebar.operations.logout.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await axios.post(`${import.meta.env.VITE_API_URL}/logout`, undefined, {
withCredentials: true,
});
await userStore.fetchCurrentUser();
router.replace({ name: "Login" });
} catch (error) {
console.error("Failed to logout", error);
}
},
});
};
// Global Search
const globalSearchVisible = ref(false);
const handleGlobalSearchKeybinding = (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = e;
if (key === "k" && ((ctrlKey && !isMac) || metaKey)) {
globalSearchVisible.value = true;
e.preventDefault();
}
};
onMounted(() => {
document.addEventListener("keydown", handleGlobalSearchKeybinding);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleGlobalSearchKeybinding);
});
// Generate menus by routes
const menus = ref<MenuGroupType[]>([] as MenuGroupType[]);
const minimenus = ref<MenuItemType[]>([] as MenuItemType[]);
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
const generateMenus = () => {
// sort by menu.priority and meta.core
const currentRoutes = sortBy(
router.getRoutes().filter((route) => {
const { meta } = route;
if (!meta?.menu) {
return false;
}
if (meta.permissions) {
return hasPermission(uiPermissions, meta.permissions as string[], true);
}
return true;
}),
[
(route: RouteRecordRaw) => !route.meta?.core,
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
]
);
// group by menu.group
menus.value = currentRoutes.reduce((acc, route) => {
const { menu } = route.meta;
if (!menu) {
return acc;
}
const group = acc.find((item) => item.id === menu.group);
const childRoute = route.children[0];
const childMetaMenu = childRoute?.meta?.menu;
// only support one level
const menuChildren = childMetaMenu
? [
{
name: childMetaMenu.name,
path: childRoute.path,
icon: childMetaMenu.icon,
},
]
: undefined;
if (group) {
group.items?.push({
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
});
} else {
const menuGroup = coreMenuGroups.find((item) => item.id === menu.group);
let name = "";
if (!menuGroup) {
name = menu.group;
} else if (menuGroup.name) {
name = menuGroup.name;
}
acc.push({
id: menuGroup?.id || menu.group,
name: name,
priority: menuGroup?.priority || 0,
items: [
{
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
},
],
});
}
return acc;
}, [] as MenuGroupType[]);
// sort by menu.priority
menus.value = sortBy(menus.value, [
(menu: MenuGroupType) => {
return coreMenuGroups.findIndex((item) => item.id === menu.id) < 0;
},
(menu: MenuGroupType) => menu.priority || 0,
]);
minimenus.value = menus.value
.reduce((acc, group) => {
if (group?.items) {
acc.push(...group.items);
}
return acc;
}, [] as MenuItemType[])
.filter((item) => item.mobile);
};
onMounted(generateMenus);
// aside scroll
const navbarScroller = ref();
const useNavbarScrollStore = defineStore("navbar", {
state: () => ({
y: 0,
}),
});
const navbarScrollStore = useNavbarScrollStore();
const reactiveParams = reactive<UseOverlayScrollbarsParams>({
options: {
scrollbars: {
autoHide: "scroll",
autoHideDelay: 600,
},
},
events: {
scroll: (_, onScrollArgs) => {
const target = onScrollArgs.target as HTMLElement;
navbarScrollStore.y = target.scrollTop;
},
updated: (instance) => {
const { viewport } = instance.elements();
if (!viewport) return;
viewport.scrollTo({ top: navbarScrollStore.y });
},
},
});
const [initialize] = useOverlayScrollbars(reactiveParams);
onMounted(() => {
if (navbarScroller.value) {
initialize({ target: navbarScroller.value });
}
});
</script>
<template>
<div class="flex h-full">
<aside
class="navbar fixed hidden h-full overflow-y-auto md:flex md:flex-col"
>
<div class="logo flex justify-center pb-7 pt-5">
<a
href="/"
target="_blank"
:title="$t('core.sidebar.operations.visit_homepage.title')"
>
<IconLogo
class="cursor-pointer select-none transition-all hover:brightness-125"
/>
</a>
</div>
<div ref="navbarScroller" class="flex-1 overflow-y-hidden">
<div class="px-3">
<div
class="flex cursor-pointer items-center rounded bg-gray-100 p-2 text-gray-400 transition-all hover:text-gray-900"
@click="globalSearchVisible = true"
>
<span class="mr-3">
<IconSearch />
</span>
<span class="flex-1 select-none text-base font-normal">
{{ $t("core.sidebar.search.placeholder") }}
</span>
<div class="text-sm">
{{ `${isMac ? "⌘" : "Ctrl"}+K` }}
</div>
</div>
</div>
<RoutesMenu :menus="menus" />
</div>
<div class="profile-placeholder">
<div class="current-profile">
<div v-if="currentUser?.spec.avatar" class="profile-avatar">
<VAvatar
:src="currentUser?.spec.avatar"
:alt="currentUser?.spec.displayName"
size="md"
circle
></VAvatar>
</div>
<div class="profile-name">
<div class="flex text-sm font-medium">
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.[0]" class="flex">
<VTag>
<template #leftIcon>
<IconUserSettings />
</template>
{{
currentRoles[0].metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || currentRoles[0].metadata.name
}}
</VTag>
</div>
</div>
<VDropdown
class="profile-control cursor-pointer rounded p-1 transition-all hover:bg-gray-100"
>
<IconMore />
<template #popper>
<VDropdownItem
@click="
$router.push({
name: 'UserDetail',
params: { name: '-' },
})
"
>
{{ $t("core.sidebar.operations.profile.button") }}
</VDropdownItem>
<VDropdownItem
@click="
$router.push({
name: 'UserNotifications',
})
"
>
{{ $t("core.sidebar.operations.notifications.button") }}
</VDropdownItem>
<VDropdownItem @click="handleLogout">
{{ $t("core.sidebar.operations.logout.button") }}
</VDropdownItem>
</template>
</VDropdown>
</div>
</div>
</aside>
<main class="content w-full pb-12 mb-safe md:pb-0">
<slot v-if="$slots.default" />
<RouterView v-else />
</main>
<!--bottom nav bar-->
<div
v-if="minimenus"
class="bottom-nav-bar fixed bottom-0 left-0 right-0 grid grid-cols-6 border-t-2 border-black bg-secondary drop-shadow-2xl mt-safe pb-safe md:hidden"
>
<div
v-for="(menu, index) in minimenus"
:key="index"
:class="{ 'bg-black': route.path === menu?.path }"
class="nav-item"
@click="router.push(menu?.path)"
>
<div
class="flex w-full cursor-pointer items-center justify-center p-1 text-white"
>
<div class="flex h-10 w-10 flex-col items-center justify-center">
<div class="text-base">
<Component :is="menu?.icon" />
</div>
</div>
</div>
</div>
<div class="nav-item" @click="moreMenuVisible = true">
<div
class="flex w-full cursor-pointer items-center justify-center p-1 text-white"
>
<div class="flex h-10 w-10 flex-col items-center justify-center">
<div class="text-base">
<IconMore />
</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-show="moreMenuRootVisible"
class="drawer-wrapper fixed left-0 top-0 z-[99999] flex h-full w-full flex-row items-end justify-center"
>
<transition
enter-active-class="ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
@before-enter="moreMenuRootVisible = true"
@after-leave="moreMenuRootVisible = false"
>
<div
v-show="moreMenuVisible"
class="drawer-layer absolute left-0 top-0 h-full w-full flex-none bg-gray-500 bg-opacity-75 transition-opacity"
@click="moreMenuVisible = false"
></div>
</transition>
<transition
enter-active-class="transform transition ease-in-out duration-500 sm:duration-700"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transform transition ease-in-out duration-500 sm:duration-700"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full"
>
<div
v-show="moreMenuVisible"
class="drawer-content relative flex h-3/4 w-screen flex-col items-stretch overflow-y-auto rounded-t-md bg-white shadow-xl"
>
<div class="drawer-body">
<RoutesMenu
:menus="menus"
class="p-0"
@select="moreMenuVisible = false"
/>
</div>
</div>
</transition>
</div>
</Teleport>
</div>
</div>
<GlobalSearchModal v-model:visible="globalSearchVisible" />
<LoginModal />
</template>
<style lang="scss">
.navbar {
@apply w-64;
@apply bg-white;
@apply shadow;
z-index: 999;
.profile-placeholder {
height: 70px;
.current-profile {
height: 70px;
@apply fixed
bottom-0
left-0
flex
w-64
gap-3
bg-white
p-3;
.profile-avatar {
@apply flex
items-center
self-center;
}
.profile-name {
@apply flex-1
self-center;
}
.profile-control {
@apply self-center;
}
}
}
}
.content {
@apply ml-0
flex
flex-auto
flex-col
overflow-x-hidden
md:ml-64;
}
</style>