452 lines
11 KiB
Vue

<template>
<n-config-provider :theme-overrides="themeOverrides" class="h-full">
<div class="relative flex h-full flex-col">
<n-layout class="h-full" has-sider>
<!-- 移动端遮罩层 -->
<div
v-if="isMobile && !sidebarCollapsed && !isSignupFlow && !isHideSidebar && session.user"
class="mobile-overlay"
@click="sidebarCollapsed = true"
></div>
<n-layout-sider
v-if="!isSignupFlow && !isHideSidebar && session.user"
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
v-model:collapsed="sidebarCollapsed"
:show-trigger="!isMobile"
:responsive="true"
:breakpoint="768"
class="app-sidebar-sider"
@collapse="sidebarCollapsed = true"
@expand="sidebarCollapsed = false"
>
<AppSidebar :collapsed="sidebarCollapsed" @menu-select="handleMenuSelect" />
</n-layout-sider>
<n-layout class="h-full">
<!-- 移动端顶部导航栏 -->
<div
v-if="isMobile && !isSignupFlow && !isHideSidebar && session.user"
class="mobile-header"
>
<div class="mobile-header-content">
<JLogo class="mobile-logo" @click="goToHome" />
<n-dropdown
:options="mobileDropdownOptions"
:show-arrow="true"
trigger="click"
v-model:show="isMobileDropdownOpen"
@select="handleMobileDropdownSelect"
placement="bottom-start"
>
<div class="mobile-user-info-wrapper" :class="{ 'mobile-user-info-wrapper--active': isMobileDropdownOpen }">
<div class="mobile-user-info">
{{ teamUserText }}
</div>
<n-icon class="mobile-chevron-icon">
<ChevronDown />
</n-icon>
</div>
</n-dropdown>
<n-button
quaternary
circle
@click="toggleSidebar"
class="mobile-menu-button"
>
<template #icon>
<n-icon><MenuIcon /></n-icon>
</template>
</n-button>
</div>
</div>
<div class="w-full h-full overflow-auto" id="scrollContainer">
<div
v-if="
!isSignupFlow &&
!isSiteLogin &&
!$session.user &&
!$route.meta.isLoginPage
"
class="border bg-red-200 px-5 py-3 text-base text-red-900"
>
You are not logged in.
<router-link to="/login" class="underline">Login</router-link> to
access dashboard.
</div>
<router-view />
</div>
</n-layout>
</n-layout>
<Toaster position="top-right" />
<component v-for="dialog in dialogs" :is="dialog" :key="dialog.id" />
<!-- 切换团队对话框 -->
<SwitchTeamDialog v-model="showTeamSwitcher" />
</div>
</n-config-provider>
</template>
<script setup>
import { defineAsyncComponent, computed, watch, ref, provide, onMounted, onUnmounted, h, getCurrentInstance } from 'vue';
import { NLayout, NLayoutSider, NConfigProvider, NButton, NIcon, NDropdown } from 'naive-ui';
import { Toaster } from 'vue-sonner';
import { dialogs } from './utils/components';
import { useRoute, useRouter } from 'vue-router';
import { getTeam } from './data/team';
import { session } from './data/session.js';
import JLogo from '@/components/icons/JLogo.vue';
import MenuIcon from '~icons/lucide/menu';
import ChevronDown from '~icons/lucide/chevron-down';
const AppSidebar = defineAsyncComponent(
() => import('./components/AppSidebar.vue'),
);
const SwitchTeamDialog = defineAsyncComponent(
() => import('./components/SwitchTeamDialog.vue'),
);
const route = useRoute();
const router = useRouter();
const team = getTeam();
const instance = getCurrentInstance();
const showTeamSwitcher = ref(false);
const isMobileDropdownOpen = ref(false);
// Naive UI 主题配置 - 使用标准方式配置 tooltip 和 menu
const themeOverrides = {
Tooltip: {
color: '#fafafa',
textColor: '#4a5568',
padding: '6px 12px',
borderRadius: '8px',
},
Menu: {
// 使用主题系统配置菜单样式,减少深度选择器
itemTextColor: '#4a5568',
itemTextColorHover: '#4a5568',
itemTextColorActive: '#18a058',
itemTextColorChildActive: '#18a058',
itemColorHover: 'rgba(0, 0, 0, 0.04)',
itemColorActive: 'rgba(24, 160, 88, 0.1)',
itemColorActiveHover: 'rgba(24, 160, 88, 0.15)',
itemBorderRadius: '8px',
itemPadding: '10px 12px',
itemIconColor: '#8b8e95',
itemIconColorHover: '#18a058',
itemIconColorActive: '#18a058',
itemIconColorChildActive: '#18a058',
itemIconSize: '18px',
fontSize: '14px',
},
};
// 移动端检测
const isMobile = ref(false);
// 侧边栏折叠状态
const sidebarCollapsed = ref(false);
// 团队用户文本(用于移动端显示)
const teamUserText = computed(() => {
if (team?.get?.loading) {
return '加载中...';
}
return team?.pg?.user || '';
});
// 响应式:在移动端默认折叠
const checkMobile = () => {
const wasMobile = isMobile.value;
isMobile.value = window.innerWidth < 768;
// 切换到移动端时自动折叠
if (!wasMobile && isMobile.value) {
sidebarCollapsed.value = true;
}
};
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// 处理菜单选择 - 移动端自动关闭侧边栏
const handleMenuSelect = () => {
if (isMobile.value) {
sidebarCollapsed.value = true;
}
};
// 切换侧边栏状态
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value;
};
// 跳转到首页
const goToHome = () => {
router.push('/').catch((err) => {
if (err.name !== 'NavigationDuplicated') {
console.error('Navigation error:', err);
}
});
};
// 移动端下拉菜单选项
const mobileDropdownOptions = computed(() => {
const options = [];
const teamData = team?.pg;
if (
teamData &&
(teamData.valid_teams?.length > 1 || teamData.is_desk_user)
) {
options.push({
label: '切换团队',
key: 'switch-team',
});
}
options.push({
label: '退出登录',
key: 'logout',
});
return options;
});
// 处理移动端下拉菜单选择
const handleMobileDropdownSelect = (key) => {
if (key === 'switch-team') {
showTeamSwitcher.value = true;
} else if (key === 'logout') {
if (session) {
session.logout.submit();
}
}
};
const isHideSidebar = computed(() => {
const alwaysHideSidebarRoutes = [
'Site Login',
'SignupLoginToSite',
'SignupSetup',
];
const alwaysHideSidebarPaths = ['/dashboard/site-login'];
if (!session.user) return false;
if (
alwaysHideSidebarRoutes.includes(route.name) ||
alwaysHideSidebarPaths.includes(window.location.pathname)
)
return true;
return (
route.meta.hideSidebar && session.user && team?.pg?.hide_sidebar === true
);
});
const isSignupFlow = ref(
window.location.pathname.startsWith('/dashboard/create-site') ||
window.location.pathname.startsWith('/dashboard/setup-account') ||
window.location.pathname.startsWith('/dashboard/site-login') ||
window.location.pathname.startsWith('/dashboard/signup'),
);
const isSiteLogin = ref(window.location.pathname.endsWith('/site-login'));
watch(
() => route.name,
() => {
isSignupFlow.value =
window.location.pathname.startsWith('/dashboard/create-site') ||
window.location.pathname.startsWith('/dashboard/setup-account') ||
window.location.pathname.startsWith('/dashboard/site-login') ||
window.location.pathname.startsWith('/dashboard/signup');
},
);
provide('team', team);
provide('session', session);
</script>
<style src="./assets/style.css"></style>
<style>
.app-sidebar-sider {
background: #fafafa !important;
border-right: 1px solid rgba(0, 0, 0, 0.06) !important;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
z-index: 1000 !important;
}
.app-sidebar-sider .n-layout-sider-trigger {
background: #fff !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
right: -16px !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.app-sidebar-sider .n-layout-sider-trigger:hover {
background: #f5f5f5 !important;
}
.app-sidebar-sider .n-layout-sider-trigger:active {
background: #eeeeee !important;
}
.app-sidebar-sider .n-layout-sider-trigger .n-base-icon {
color: #666;
font-size: 14px;
transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.app-sidebar-sider .n-layout-sider-scroll-container {
height: 100%;
overflow: hidden;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
n-config-provider {
height: 100%;
display: flex;
flex-direction: column;
}
.app-sidebar-sider {
height: 100% !important;
min-height: 100vh !important;
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mobile-header {
position: sticky;
top: 0;
z-index: 999;
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.mobile-header-content {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 12px 16px;
gap: 12px;
}
.mobile-logo {
width: 28px;
height: 28px;
flex-shrink: 0;
cursor: pointer;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.mobile-logo:hover {
opacity: 0.8;
}
.mobile-user-info-wrapper {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 1;
min-width: 0;
max-width: calc(100% - 80px);
}
.mobile-user-info-wrapper:active {
background: rgba(0, 0, 0, 0.06);
}
.mobile-user-info-wrapper--active {
background: rgba(0, 0, 0, 0.04);
}
.mobile-user-info {
font-size: 14px;
font-weight: 500;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
}
.mobile-chevron-icon {
font-size: 16px;
color: #8b8e95;
flex-shrink: 0;
transition: color 0.2s ease, transform 0.2s ease;
}
.mobile-user-info-wrapper:hover .mobile-chevron-icon {
color: #4a5568;
}
.mobile-menu-button {
flex-shrink: 0;
margin-left: auto;
}
@media (max-width: 767px) {
.app-sidebar-sider {
position: fixed !important;
top: 0;
left: 0;
height: 100vh !important;
z-index: 1000 !important;
width: 280px !important;
max-width: 85vw;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.app-sidebar-sider:not(.n-layout-sider--collapsed) {
transform: translateX(0) !important;
}
.n-layout {
margin-left: 0 !important;
}
}
@media (min-width: 768px) {
.app-sidebar-sider {
position: relative !important;
transform: none !important;
}
.mobile-header {
display: none;
}
.mobile-overlay {
display: none;
}
}
</style>