jcloud/dashboard/src2/components/AppSidebar.vue

490 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="sidebar-container">
<!-- 用户信息区域 -->
<div class="sidebar-header" :class="{ 'sidebar-header-collapsed': collapsed }">
<n-dropdown
:options="dropdownOptions"
:show-arrow="true"
trigger="click"
@select="handleDropdownSelect"
>
<div
class="user-info"
:class="{ 'user-info-collapsed': collapsed }"
>
<JLogo class="logo-icon" />
<div v-if="!collapsed" class="user-info-text">
<div class="brand-name">今果 Jingrow</div>
<n-tooltip>
<template #trigger>
<div class="user-name">
{{ teamUserText }}
</div>
</template>
{{ teamUserText }}
</n-tooltip>
</div>
</div>
</n-dropdown>
</div>
<!-- 导航菜单 -->
<div class="sidebar-menu">
<NavigationItems>
<template v-slot="{ navigation }">
<n-menu
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="20"
:options="convertToMenuOptions(navigation)"
:value="activeKey"
:router="false"
@update:value="handleMenuSelect"
class="app-sidebar-menu"
/>
</template>
</NavigationItems>
</div>
</div>
<!-- 切换团队对话框 -->
<SwitchTeamDialog2 v-model="showTeamSwitcher" />
</template>
<script>
import { defineAsyncComponent, computed, ref, h, getCurrentInstance } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NMenu, NDropdown, NTooltip, NIcon } from 'naive-ui';
import NavigationItems from './NavigationItems.vue';
import JLogo from '@/components/icons/JLogo.vue';
export default {
name: 'AppSidebar',
components: {
NMenu,
NDropdown,
NTooltip,
NIcon,
NavigationItems,
JLogo,
SwitchTeamDialog2: defineAsyncComponent(() =>
import('./SwitchTeamDialog.vue')
),
},
props: {
collapsed: {
type: Boolean,
default: false,
},
},
emits: ['update:collapsed'],
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const instance = getCurrentInstance();
const showTeamSwitcher = ref(false);
// 响应式:在移动端默认折叠
const isMobile = computed(() => {
if (typeof window === 'undefined') return false;
return window.innerWidth < 768;
});
const collapsed = computed({
get: () => {
// 如果 props 有值,使用 props否则根据屏幕大小判断
if (props.collapsed !== undefined) {
return props.collapsed;
}
return isMobile.value;
},
set: (value) => emit('update:collapsed', value),
});
// 监听窗口大小变化
if (typeof window !== 'undefined') {
const handleResize = () => {
if (window.innerWidth < 768 && !props.collapsed) {
emit('update:collapsed', true);
}
};
window.addEventListener('resize', handleResize);
}
// 下拉菜单选项
const dropdownOptions = computed(() => {
const options = [];
// 通过 getCurrentInstance 访问全局属性
const team = instance?.appContext.config.globalProperties.$team?.pg;
if (
team &&
(team.valid_teams?.length > 1 || team.is_desk_user)
) {
options.push({
label: '切换团队',
key: 'switch-team',
icon: () => h('i', { class: 'i-lucide-command h-4 w-4' }),
});
}
options.push({
label: '退出登录',
key: 'logout',
icon: () => h('i', { class: 'i-lucide-log-out h-4 w-4' }),
});
return options;
});
// 处理下拉菜单选择
const handleDropdownSelect = (key) => {
if (key === 'switch-team') {
showTeamSwitcher.value = true;
} else if (key === 'logout') {
const session = instance?.appContext.config.globalProperties.$session;
if (session) {
session.logout.submit();
}
}
};
// 转换导航项为菜单选项
const convertToMenuOptions = (items) => {
if (!items || !Array.isArray(items)) {
console.warn('[AppSidebar] convertToMenuOptions: invalid items', items);
return [];
}
return items
.filter((item) => item.condition !== false)
.map((item) => {
// 处理图标item.icon 是一个函数,返回 h() 创建的组件
// Naive UI 的 n-menu 需要 icon 是一个渲染函数
let iconComponent = undefined;
if (item.icon) {
iconComponent = () => {
try {
const iconVNode = item.icon();
// 如果图标 VNode 没有 props添加默认的 size
if (iconVNode && !iconVNode.props) {
iconVNode.props = { width: 16, height: 16 };
} else if (iconVNode && iconVNode.props) {
// 确保有 size 属性
if (!iconVNode.props.width && !iconVNode.props.size) {
iconVNode.props.width = 16;
}
if (!iconVNode.props.height && !iconVNode.props.size) {
iconVNode.props.height = 16;
}
}
// 用 NIcon 包装
return h(NIcon, null, { default: () => iconVNode });
} catch (e) {
console.error(`[AppSidebar] Icon error for "${item.name}":`, e);
return null;
}
};
}
// 处理路由NavigationItems 中的 route 已经是相对于 /dashboard/ 的路径(如 '/sites'
// 路由配置的 base 是 '/dashboard/',所以直接使用 item.route 即可
let routePath = item.route || item.name;
const option = {
label: item.name,
key: routePath,
icon: iconComponent,
disabled: item.disabled,
};
// 处理子菜单
if (item.children && item.children.length > 0) {
option.children = convertToMenuOptions(item.children);
}
// 处理徽章
if (item.badge) {
try {
// item.badge 是一个函数,调用它获取徽章组件
const badgeResult = item.badge();
if (badgeResult) {
option.label = () => h('div', {
class: 'flex items-center justify-between w-full'
}, [
h('span', item.name),
badgeResult,
]);
}
} catch (e) {
// 徽章处理失败,使用默认标签
console.warn('Badge rendering failed:', e);
}
}
return option;
});
};
// 当前激活的菜单项
const activeKey = computed(() => {
// 路由配置的 base 是 '/dashboard/'route.path 返回的是相对于 base 的路径
// 例如:如果访问 /dashboard/sitesroute.path 应该是 '/sites'
let path = route.path;
// 确保路径格式正确(以 / 开头)
if (!path.startsWith('/')) {
path = '/' + path;
}
// 如果路径是 /dashboard转换为 /
if (path === '/dashboard') {
path = '/';
}
return path;
});
// 处理菜单选择
const handleMenuSelect = (key) => {
// 路由配置的 base 是 '/dashboard/'key 已经是相对于 base 的路径(如 '/sites'
// 直接使用 router.pushVue Router 会自动处理 base
router.push(key).catch((err) => {
// 忽略导航重复的错误
if (err.name !== 'NavigationDuplicated') {
console.error('Navigation error:', err);
}
});
};
// 团队用户文本
const teamUserText = computed(() => {
const team = instance?.appContext.config.globalProperties.$team;
if (team?.get?.loading) {
return '加载中...';
}
return team?.pg?.user || '';
});
return {
showTeamSwitcher,
collapsed,
dropdownOptions,
activeKey,
teamUserText,
handleDropdownSelect,
handleMenuSelect,
convertToMenuOptions,
};
},
};
</script>
<style scoped>
/* 侧边栏容器 */
.sidebar-container {
display: block;
height: 100%;
background: #fafafa;
}
/* 用户信息区域 */
.sidebar-header {
padding: 16px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-header-collapsed {
padding: 16px 8px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.user-info:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-info-collapsed {
justify-content: center;
padding: 8px;
gap: 0;
}
.user-info-collapsed .logo-icon {
margin: 0 auto;
}
.logo-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.user-info-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.brand-name {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
}
.user-name {
font-size: 13px;
color: #666;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 菜单区域 */
.sidebar-menu {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
}
/* 菜单项样式优化 */
:deep(.app-sidebar-menu .n-menu-item) {
margin: 1px 8px;
}
:deep(.app-sidebar-menu .n-menu-item-content) {
border-radius: 8px;
padding: 10px 12px;
margin: 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 14px;
color: #4a5568;
line-height: 1.5;
position: relative;
}
:deep(.app-sidebar-menu .n-menu-item-content:hover) {
background: rgba(0, 0, 0, 0.04);
}
:deep(.app-sidebar-menu .n-menu-item-content.n-menu-item-content--selected) {
background: rgba(24, 160, 88, 0.1);
color: #18a058;
font-weight: 500;
}
:deep(.app-sidebar-menu .n-menu-item-content.n-menu-item-content--selected::before) {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: #18a058;
border-radius: 0 2px 2px 0;
}
/* 折叠状态下的菜单项 */
:deep(.app-sidebar-menu.n-menu--collapsed .n-menu-item) {
margin: 2px 8px;
}
:deep(.app-sidebar-menu.n-menu--collapsed .n-menu-item-content) {
justify-content: center;
padding: 10px;
margin: 0;
border-radius: 8px;
}
:deep(.app-sidebar-menu.n-menu--collapsed .n-menu-item-content.n-menu-item-content--selected::before) {
display: none;
}
/* 图标样式优化 - 更轻量优雅 */
:deep(.app-sidebar-menu .n-icon) {
color: #8b8e95;
transition: color 0.2s ease;
font-size: 18px;
}
:deep(.app-sidebar-menu .n-icon svg) {
width: 18px;
height: 18px;
stroke-width: 1.2;
display: block;
}
/* 折叠状态下的图标 */
:deep(.app-sidebar-menu.n-menu--collapsed .n-icon) {
font-size: 20px;
}
:deep(.app-sidebar-menu.n-menu--collapsed .n-icon svg) {
width: 20px;
height: 20px;
stroke-width: 1.2;
}
/* 选中和悬停状态的图标颜色 */
:deep(.app-sidebar-menu .n-menu-item-content.n-menu-item-content--selected .n-icon) {
color: #18a058;
}
:deep(.app-sidebar-menu .n-menu-item-content:hover .n-icon) {
color: #18a058;
}
/* 菜单标签样式 */
:deep(.app-sidebar-menu .n-menu-item-content__icon) {
margin-right: 12px;
}
:deep(.app-sidebar-menu.n-menu--collapsed .n-menu-item-content__icon) {
margin-right: 0;
}
/* 子菜单样式 */
:deep(.app-sidebar-menu .n-submenu) {
margin: 2px 8px;
}
:deep(.app-sidebar-menu .n-submenu .n-submenu-children) {
padding-left: 0;
}
:deep(.app-sidebar-menu .n-submenu .n-menu-item-content) {
padding-left: 36px;
}
/* 滚动条样式 */
.sidebar-menu::-webkit-scrollbar {
width: 4px;
}
.sidebar-menu::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-menu::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.sidebar-menu::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.15);
}
</style>