feat: improve menu bar in mobile devices (#7542)

#### What type of PR is this?

/area ui
/kind improvement
/milestone 2.21.x

#### What this PR does / why we need it:

<img width="408" alt="image" src="https://github.com/user-attachments/assets/296c631f-59e2-4aaa-9c0b-cabb0f8ee2d9" />

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/7410
Fixes https://github.com/halo-dev/halo/issues/2885

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
优化移动端的菜单,支持切换 Console / UC,支持退出登录和返回到首页。
```
pull/7549/head
Ryan Wang 2025-06-13 13:28:42 +08:00 committed by GitHub
parent 4a3d35a900
commit 663ee22147
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 685 additions and 533 deletions

View File

@ -2,55 +2,22 @@
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
import { rbacAnnotations } from "@/constants/annotations";
import { useUserStore } from "@/stores/user";
import MobileMenu from "@/layouts/MobileMenu.vue";
import UserProfileBanner from "@/layouts/UserProfileBanner.vue";
import { isMac } from "@/utils/device";
import { coreMenuGroups } from "@console/router/constant";
import {
Dialog,
IconAccountCircleLine,
IconArrowDownLine,
IconLogoutCircleRLine,
IconMore,
IconSearch,
IconShieldUser,
VAvatar,
VDropdown,
VTag,
} from "@halo-dev/components";
import { IconSearch } from "@halo-dev/components";
import { useEventListener } from "@vueuse/core";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import { defineStore, storeToRefs } from "pinia";
import { defineStore } from "pinia";
import { onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { RouterView, useRoute, useRouter } from "vue-router";
import { RouterView, useRoute } from "vue-router";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
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"),
description: t("core.sidebar.operations.logout.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
window.location.href = "/logout";
},
});
};
// Global Search
const globalSearchVisible = ref(false);
@ -103,223 +70,49 @@ onMounted(() => {
</script>
<template>
<div class="flex min-h-screen">
<aside
class="navbar fixed hidden h-full overflow-y-auto md:flex md:flex-col"
>
<div class="logo flex justify-center pb-5 pt-5">
<div class="layout">
<aside class="sidebar">
<div class="sidebar__logo-container">
<a
href="/"
target="_blank"
:title="$t('core.sidebar.operations.visit_homepage.title')"
>
<IconLogo
class="cursor-pointer select-none transition-all hover:brightness-125"
/>
<IconLogo class="sidebar__logo" />
</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">
<div ref="navbarScroller" class="sidebar__content">
<div class="sidebar__search-wrapper">
<div class="sidebar__search" @click="globalSearchVisible = true">
<span class="sidebar__search-icon">
<IconSearch />
</span>
<span class="flex-1 select-none text-base font-normal">
<span class="sidebar__search-text">
{{ $t("core.sidebar.search.placeholder") }}
</span>
<div class="text-sm">
<div class="sidebar__search-shortcut">
{{ `${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="sm"
circle
></VAvatar>
</div>
<div class="profile-name">
<div
class="flex text-sm font-medium"
:title="currentUser?.spec.displayName"
>
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.length" class="flex mt-1">
<VTag v-if="currentRoles.length === 1">
<template #leftIcon>
<IconShieldUser />
</template>
{{
currentRoles[0].metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || currentRoles[0].metadata.name
}}
</VTag>
<VDropdown v-else>
<div class="flex gap-1">
<VTag>
<template #leftIcon>
<IconShieldUser />
</template>
{{ $t("core.sidebar.profile.aggregate_role") }}
</VTag>
<IconArrowDownLine />
</div>
<template #popper>
<div class="p-1">
<h2
class="text-gray-600 text-sm font-semibold border-b border-gray-100 pb-1.5"
>
{{ $t("core.sidebar.profile.aggregate_role") }}
</h2>
<div class="flex gap-2 flex-wrap mt-2">
<VTag
v-for="role in currentRoles"
:key="role.metadata.name"
>
<template #leftIcon>
<IconShieldUser />
</template>
{{
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name
}}
</VTag>
</div>
</div>
</template>
</VDropdown>
</div>
</div>
<div class="flex items-center gap-1">
<a
v-tooltip="$t('core.sidebar.operations.profile.tooltip')"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
href="/uc"
>
<IconAccountCircleLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</a>
<div
v-tooltip="$t('core.sidebar.operations.logout.tooltip')"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
@click="handleLogout"
>
<IconLogoutCircleRLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</div>
<div class="sidebar__profile">
<UserProfileBanner platform="console" />
</div>
</aside>
<main class="content w-full pb-12 mb-safe md:w-[calc(100%-16rem)] md:pb-0">
<main class="main-content">
<slot v-if="$slots.default" />
<RouterView v-else />
<footer
v-if="!route.meta.hideFooter"
class="mt-auto p-4 text-center text-sm"
>
<span class="text-gray-600">Powered by </span>
<RouterLink to="/overview" class="hover:text-gray-600">
<footer v-if="!route.meta.hideFooter" class="main-content__footer">
<span class="main-content__footer-text">Powered by </span>
<RouterLink to="/overview" class="main-content__footer-link">
Halo
</RouterLink>
</footer>
</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>
<MobileMenu :menus="menus" :minimenus="minimenus" platform="console" />
</div>
<GlobalSearchModal
v-if="globalSearchVisible"
@ -328,47 +121,117 @@ onMounted(() => {
</template>
<style lang="scss">
.navbar {
@apply w-64;
@apply bg-white;
@apply shadow;
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
position: fixed;
width: theme("width.64");
height: 100%;
background-color: theme("colors.white");
box-shadow: theme("boxShadow.DEFAULT");
z-index: 999;
overflow-y: auto;
display: none;
flex-direction: column;
.profile-placeholder {
height: 70px;
flex: none;
@media (min-width: theme("screens.md")) {
display: flex;
}
.current-profile {
height: 70px;
@apply fixed
bottom-0
left-0
flex
w-64
gap-3
bg-white
p-3;
&__logo-container {
display: flex;
justify-content: center;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.profile-avatar {
@apply flex
items-center
self-center;
}
&__logo {
cursor: pointer;
user-select: none;
transition: all;
.profile-name {
@apply flex-1
self-center
overflow-hidden;
}
&:hover {
filter: brightness(1.25);
}
}
&__content {
flex: 1;
overflow-y: hidden;
}
&__search-wrapper {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
&__search {
display: flex;
cursor: pointer;
align-items: center;
border-radius: 0.25rem;
background-color: theme("colors.gray.100");
padding: 0.5rem;
color: theme("colors.gray.400");
transition: all;
&:hover {
color: theme("colors.gray.900");
}
}
&__search-icon {
margin-right: 0.75rem;
}
&__search-text {
flex: 1;
user-select: none;
font-size: 1rem;
font-weight: normal;
}
&__search-shortcut {
font-size: 0.875rem;
}
&__profile {
flex: none;
}
}
.content {
@apply ml-0
flex
flex-auto
flex-col
md:ml-64;
.main-content {
width: 100%;
padding-bottom: 3rem;
margin-bottom: env(safe-area-inset-bottom);
display: flex;
flex: auto;
flex-direction: column;
@media (min-width: theme("screens.md")) {
width: calc(100% - 16rem);
margin-left: theme("width.64");
padding-bottom: 0;
}
&__footer {
margin-top: auto;
padding: 1rem;
text-align: center;
font-size: 0.875rem;
}
&__footer-text {
color: theme("colors.gray.600");
}
&__footer-link {
&:hover {
color: theme("colors.gray.600");
}
}
}
</style>

View File

@ -4,12 +4,13 @@ import { i18n } from "@/locales";
import { useGlobalInfoStore } from "@/stores/global-info";
import type { FormKitConfig } from "@formkit/core";
import { useFavicon } from "@vueuse/core";
import type { OverlayScrollbars } from "overlayscrollbars";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import { storeToRefs } from "pinia";
import { computed, inject, onMounted, reactive } from "vue";
import { computed, inject, onMounted, provide, reactive } from "vue";
import { RouterView } from "vue-router";
useAppTitle();
@ -35,11 +36,13 @@ const reactiveParams = reactive<UseOverlayScrollbarsParams>({
},
defer: true,
});
const [initialize] = useOverlayScrollbars(reactiveParams);
const [initialize, instance] = useOverlayScrollbars(reactiveParams);
onMounted(() => {
if (body) initialize({ target: body });
});
provide<() => OverlayScrollbars | null>("bodyScrollInstance", instance);
// setup formkit locale
// see https://formkit.com/essentials/internationalization
const formkitLocales = {

View File

@ -0,0 +1,218 @@
<script lang="ts" setup>
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import { IconMore, VMenu, VMenuItem } from "@halo-dev/components";
import type { OverlayScrollbars } from "overlayscrollbars";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import type { MenuGroupType, MenuItemType } from "packages/shared/dist";
import { inject, ref, watch } from "vue";
import { useRoute } from "vue-router";
import RiArrowLeftLine from "~icons/ri/arrow-left-line";
import UserProfileBanner from "./UserProfileBanner.vue";
defineProps<{
platform?: "console" | "uc";
menus: MenuGroupType[];
minimenus: MenuItemType[];
}>();
const route = useRoute();
const moreMenuVisible = ref(false);
const bodyScrollInstance =
inject<() => OverlayScrollbars | null>("bodyScrollInstance");
watch(
() => moreMenuVisible.value,
(value) => {
// Lock body scroll when the drawer is open
bodyScrollInstance?.()?.options({
overflow: {
x: value ? "hidden" : "scroll",
y: value ? "hidden" : "scroll",
},
});
}
);
function handleSelectHome() {
window.open("/", "_blank");
}
</script>
<template>
<div v-if="minimenus" class="mobile-nav mobile-nav--fixed">
<div
v-for="(menu, index) in minimenus"
:key="index"
:class="{ 'mobile-nav__item--active': route.path === menu?.path }"
class="mobile-nav__item"
@click="$router.push(menu?.path)"
>
<div class="mobile-nav__button">
<div class="mobile-nav__icon-container">
<div class="mobile-nav__icon">
<Component :is="menu?.icon" />
</div>
</div>
</div>
</div>
<div class="mobile-nav__item" @click="moreMenuVisible = true">
<div class="mobile-nav__button">
<div class="mobile-nav__icon-container">
<div class="mobile-nav__icon">
<IconMore />
</div>
</div>
</div>
</div>
<Teleport to="body">
<div v-if="moreMenuVisible" class="drawer drawer--visible">
<transition
enter-active-class="ease-out duration-400"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
appear
>
<div class="drawer__overlay" @click="moreMenuVisible = false"></div>
</transition>
<transition
enter-active-class="transform transition ease-out duration-500"
enter-from-class="translate-y-full scale-95"
enter-to-class="translate-y-0 scale-100"
leave-active-class="transform transition ease-in duration-300"
leave-from-class="translate-y-0 scale-100"
leave-to-class="translate-y-full scale-95"
appear
>
<div class="drawer__content">
<OverlayScrollbarsComponent
element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }"
class="drawer__body"
defer
>
<VMenu class="!pb-1">
<VMenuItem
id="home"
:title="$t('core.sidebar.menu.items.home')"
@select="handleSelectHome"
>
<template #icon>
<RiArrowLeftLine />
</template>
</VMenuItem>
</VMenu>
<RoutesMenu :menus="menus" @select="moreMenuVisible = false" />
</OverlayScrollbarsComponent>
<div class="drawer__footer">
<UserProfileBanner :platform="platform" />
</div>
</div>
</transition>
</div>
</Teleport>
</div>
</template>
<style lang="scss">
.mobile-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: grid;
grid-template-columns: repeat(6, 1fr);
border-top: 2px solid theme("colors.black");
background-color: theme("colors.secondary");
box-shadow: theme("dropShadow.2xl");
margin-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
@media (min-width: theme("screens.md")) {
display: none;
}
&__item {
&--active {
background-color: theme("colors.black");
}
}
&__button {
display: flex;
width: 100%;
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0.25rem;
color: theme("colors.white");
}
&__icon-container {
display: flex;
height: 2.5rem;
width: 2.5rem;
flex-direction: column;
align-items: center;
justify-content: center;
}
&__icon {
font-size: 1rem;
}
}
.drawer {
position: fixed;
left: 0;
top: 0;
z-index: 999;
display: flex;
height: 100%;
width: 100%;
flex-direction: row;
align-items: flex-end;
justify-content: center;
&__overlay {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
flex: none;
background-color: theme("colors.gray.500");
opacity: 0.75;
transition: opacity;
}
&__content {
position: relative;
display: flex;
height: 75%;
width: 100vw;
flex-direction: column;
align-items: stretch;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
background-color: theme("colors.white");
box-shadow: theme("boxShadow.xl");
}
&__body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
&__footer {
width: 100%;
flex: none;
}
}
</style>

View File

@ -0,0 +1,248 @@
<script lang="ts" setup>
import { rbacAnnotations } from "@/constants/annotations";
import { SUPER_ROLE_NAME } from "@/constants/constants";
import { useUserStore } from "@/stores/user";
import {
Dialog,
IconAccountCircleLine,
IconArrowDownLine,
IconLogoutCircleRLine,
IconSettings3Line,
IconShieldUser,
VAvatar,
VDropdown,
VTag,
} from "@halo-dev/components";
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{
platform?: "console" | "uc";
}>();
const { t } = useI18n();
const userStore = useUserStore();
const { currentRoles, currentUser } = storeToRefs(userStore);
const handleLogout = () => {
Dialog.warning({
title: t("core.sidebar.operations.logout.title"),
description: t("core.sidebar.operations.logout.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
window.location.href = "/logout";
},
});
};
const disallowAccessConsole = computed(() => {
if (
currentRoles?.value?.some((role) => role.metadata.name === SUPER_ROLE_NAME)
) {
return false;
}
const hasDisallowAccessConsoleRole = currentRoles?.value?.some((role) => {
return (
role.metadata.annotations?.[rbacAnnotations.DISALLOW_ACCESS_CONSOLE] ===
"true"
);
});
return !!hasDisallowAccessConsoleRole;
});
const actions = computed(() => {
const items = [
{
label: t("core.sidebar.operations.logout.tooltip"),
icon: IconLogoutCircleRLine,
onClick: handleLogout,
},
];
if (props.platform === "console") {
items.unshift({
label: t("core.sidebar.operations.profile.tooltip"),
icon: IconAccountCircleLine,
onClick: () => {
window.location.href = "/uc";
},
});
}
if (props.platform === "uc" && !disallowAccessConsole.value) {
items.unshift({
label: t("core.uc_sidebar.operations.console.tooltip"),
icon: IconSettings3Line,
onClick: () => {
window.location.href = "/console";
},
});
}
return items;
});
</script>
<template>
<div class="user-profile">
<div v-if="currentUser?.spec.avatar" class="user-profile__avatar">
<VAvatar
:src="currentUser?.spec.avatar"
:alt="currentUser?.spec.displayName"
size="sm"
circle
></VAvatar>
</div>
<div class="user-profile__info">
<div class="user-profile__name" :title="currentUser?.spec.displayName">
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.length" class="user-profile__roles">
<VTag v-if="currentRoles.length === 1">
<template #leftIcon>
<IconShieldUser />
</template>
{{
currentRoles[0].metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || currentRoles[0].metadata.name
}}
</VTag>
<VDropdown v-else :triggers="['click']">
<div class="user-profile__roles-dropdown">
<VTag>
<template #leftIcon>
<IconShieldUser />
</template>
{{ $t("core.sidebar.profile.aggregate_role") }}
</VTag>
<IconArrowDownLine />
</div>
<template #popper>
<div class="user-profile__roles-popper">
<h2 class="user-profile__roles-title">
{{ $t("core.sidebar.profile.aggregate_role") }}
</h2>
<div class="user-profile__roles-list">
<VTag
v-for="role in currentRoles"
:key="role.metadata.name"
class="user-profile__role-tag"
>
<template #leftIcon>
<IconShieldUser />
</template>
{{
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
role.metadata.name
}}
</VTag>
</div>
</div>
</template>
</VDropdown>
</div>
</div>
<div class="user-profile__actions">
<button
v-for="action in actions"
:key="action.label"
v-tooltip="action.label"
class="user-profile__action-button"
@click="action.onClick"
>
<component :is="action.icon" class="user-profile__action-icon" />
</button>
</div>
</div>
</template>
<style lang="scss">
.user-profile {
height: 70px;
display: flex;
width: 100%;
gap: 0.75rem;
background-color: theme("colors.white");
padding: 0.75rem;
&__avatar {
display: flex;
align-items: center;
align-self: center;
}
&__info {
flex: 1;
align-self: center;
overflow: hidden;
}
&__name {
display: flex;
font-size: 0.875rem;
font-weight: 500;
}
&__roles {
display: flex;
margin-top: 0.25rem;
}
&__roles-dropdown {
display: flex;
gap: 0.25rem;
}
&__roles-popper {
padding: 0.25rem;
}
&__roles-title {
color: theme("colors.gray.600");
font-size: 0.875rem;
font-weight: 600;
border-bottom: 1px solid theme("colors.gray.100");
padding-bottom: 0.375rem;
}
&__roles-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
&__actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
&__action-button {
display: inline-block;
cursor: pointer;
border-radius: 9999px;
padding: 0.375rem;
transition: all;
&:hover {
background-color: theme("colors.gray.100");
}
}
&__action-icon {
height: 1.25rem;
width: 1.25rem;
color: theme("colors.gray.600");
.user-profile__action-button:hover & {
color: theme("colors.gray.900");
}
}
}
</style>

View File

@ -2,6 +2,7 @@ core:
sidebar:
menu:
items:
home: Home
tools: Tools
operations:
logout:
@ -30,7 +31,6 @@ core:
label: Enable animation
presets:
recent_published:
publishTime: Publish Time {publishTime}
empty:
title: No published posts
notification:

View File

@ -9,6 +9,7 @@ core:
system: System
tool: Tool
items:
home: Home
dashboard: Dashboard
posts: Posts
single_pages: Pages

View File

@ -22,6 +22,7 @@ core:
overview: 概览
backup: 备份
tools: 工具
home: 首页
operations:
logout:
tooltip: 退出登录

View File

@ -22,6 +22,7 @@ core:
overview: 概覽
backup: 備份
tools: 工具
home: 首页
operations:
logout:
tooltip: 登出

View File

@ -1,53 +1,19 @@
<script lang="ts" setup>
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
import { rbacAnnotations } from "@/constants/annotations";
import { SUPER_ROLE_NAME } from "@/constants/constants";
import { useUserStore } from "@/stores/user";
import MobileMenu from "@/layouts/MobileMenu.vue";
import UserProfileBanner from "@/layouts/UserProfileBanner.vue";
import { coreMenuGroups } from "@console/router/constant";
import {
Dialog,
IconArrowDownLine,
IconLogoutCircleRLine,
IconMore,
IconSettings3Line,
IconShieldUser,
VAvatar,
VDropdown,
VTag,
} from "@halo-dev/components";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import { defineStore, storeToRefs } from "pinia";
import { computed, onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { RouterView, useRoute, useRouter } from "vue-router";
import { defineStore } from "pinia";
import { onMounted, reactive, ref } from "vue";
import { RouterView, useRoute } from "vue-router";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
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"),
description: t("core.sidebar.operations.logout.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
window.location.href = "/logout";
},
});
};
const { menus, minimenus } = useRouteMenuGenerator(coreMenuGroups);
@ -87,272 +53,123 @@ onMounted(() => {
initialize({ target: navbarScroller.value });
}
});
const disallowAccessConsole = computed(() => {
if (
currentRoles?.value?.some((role) => role.metadata.name === SUPER_ROLE_NAME)
) {
return false;
}
const hasDisallowAccessConsoleRole = currentRoles?.value?.some((role) => {
return (
role.metadata.annotations?.[rbacAnnotations.DISALLOW_ACCESS_CONSOLE] ===
"true"
);
});
return !!hasDisallowAccessConsoleRole;
});
</script>
<template>
<div class="flex min-h-screen">
<aside
class="navbar fixed hidden h-full overflow-y-auto md:flex md:flex-col"
>
<div class="logo flex justify-center pb-5 pt-5">
<div class="layout">
<aside class="sidebar">
<div class="sidebar__logo-container">
<a
href="/"
target="_blank"
:title="$t('core.sidebar.operations.visit_homepage.title')"
>
<IconLogo
class="cursor-pointer select-none transition-all hover:brightness-125"
/>
<IconLogo class="sidebar__logo" />
</a>
</div>
<div ref="navbarScroller" class="flex-1 overflow-y-hidden">
<div ref="navbarScroller" class="sidebar__content">
<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="sm"
circle
></VAvatar>
</div>
<div class="profile-name">
<div class="flex text-sm font-medium">
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.length" class="flex mt-1">
<VTag v-if="currentRoles.length === 1">
<template #leftIcon>
<IconShieldUser />
</template>
{{
currentRoles[0].metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || currentRoles[0].metadata.name
}}
</VTag>
<VDropdown v-else>
<div class="flex gap-1">
<VTag>
<template #leftIcon>
<IconShieldUser />
</template>
{{ $t("core.uc_sidebar.profile.aggregate_role") }}
</VTag>
<IconArrowDownLine />
</div>
<template #popper>
<div class="p-1">
<h2
class="text-gray-600 text-sm font-semibold border-b border-gray-100 pb-1.5"
>
{{ $t("core.uc_sidebar.profile.aggregate_role") }}
</h2>
<div class="flex gap-2 flex-wrap mt-2">
<VTag
v-for="role in currentRoles"
:key="role.metadata.name"
>
<template #leftIcon>
<IconShieldUser />
</template>
{{
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name
}}
</VTag>
</div>
</div>
</template>
</VDropdown>
</div>
</div>
<div class="flex items-center gap-1">
<a
v-if="!disallowAccessConsole"
v-tooltip="$t('core.uc_sidebar.operations.console.tooltip')"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
href="/console"
>
<IconSettings3Line
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</a>
<div
v-tooltip="$t('core.sidebar.operations.logout.tooltip')"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
@click="handleLogout"
>
<IconLogoutCircleRLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</div>
<div class="sidebar__profile">
<UserProfileBanner platform="uc" />
</div>
</aside>
<main class="content w-full pb-12 mb-safe md:w-[calc(100%-16rem)] md:pb-0">
<main class="main-content">
<slot v-if="$slots.default" />
<RouterView v-else />
<footer
v-if="!route.meta.hideFooter"
class="mt-auto p-4 text-center text-sm"
>
<span class="text-gray-600">Powered by </span>
<footer v-if="!route.meta.hideFooter" class="main-content__footer">
<span class="main-content__footer-text">Powered by </span>
<a
href="https://www.halo.run"
target="_blank"
class="hover:text-gray-600"
class="main-content__footer-link"
>
Halo
</a>
</footer>
</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>
<MobileMenu :menus="menus" :minimenus="minimenus" platform="uc" />
</div>
</template>
<style lang="scss">
.navbar {
@apply w-64;
@apply bg-white;
@apply shadow;
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
position: fixed;
width: theme("width.64");
height: 100%;
background-color: theme("colors.white");
box-shadow: theme("boxShadow.DEFAULT");
z-index: 999;
overflow-y: auto;
display: none;
flex-direction: column;
.profile-placeholder {
height: 70px;
flex: none;
@media (min-width: theme("screens.md")) {
display: flex;
}
.current-profile {
height: 70px;
@apply fixed
bottom-0
left-0
flex
w-64
gap-3
bg-white
p-3;
&__logo-container {
display: flex;
justify-content: center;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.profile-avatar {
@apply flex
items-center
self-center;
}
&__logo {
cursor: pointer;
user-select: none;
transition: all;
.profile-name {
@apply flex-1
self-center
overflow-hidden;
}
&:hover {
filter: brightness(1.25);
}
}
&__content {
flex: 1;
overflow-y: hidden;
}
&__profile {
flex: none;
}
}
.content {
@apply ml-0
flex
flex-auto
flex-col
md:ml-64;
.main-content {
width: 100%;
padding-bottom: 3rem;
margin-bottom: env(safe-area-inset-bottom);
display: flex;
flex: auto;
flex-direction: column;
@media (min-width: theme("screens.md")) {
width: calc(100% - 16rem);
margin-left: theme("width.64");
padding-bottom: 0;
}
&__footer {
margin-top: auto;
padding: 1rem;
text-align: center;
font-size: 0.875rem;
}
&__footer-text {
color: theme("colors.gray.600");
}
&__footer-link {
&:hover {
color: theme("colors.gray.600");
}
}
}
</style>