jcloud/dashboard/src2/components/AppSidebar.vue
2025-12-28 02:25:18 +08:00

518 lines
13 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>
<div class="user-name">
{{ teamUserText }}
</div>
</div>
<n-icon v-if="!collapsed" class="chevron-icon">
<ChevronDown />
</n-icon>
</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, NIcon } from 'naive-ui';
import NavigationItems from './NavigationItems.vue';
import JLogo from '@/components/icons/JLogo.vue';
import ChevronDown from '~icons/lucide/chevron-down';
export default {
name: 'AppSidebar',
components: {
NMenu,
NDropdown,
NIcon,
NavigationItems,
JLogo,
ChevronDown,
SwitchTeamDialog2: defineAsyncComponent(() =>
import('./SwitchTeamDialog.vue')
),
},
props: {
collapsed: {
type: Boolean,
default: false,
},
},
emits: ['update:collapsed'],
setup(props) {
const route = useRoute();
const router = useRouter();
const instance = getCurrentInstance();
const showTeamSwitcher = ref(false);
// 简化:直接使用 props响应式处理由父组件负责
const collapsed = computed(() => props.collapsed);
// 下拉菜单选项
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) => {
// 处理图标:简化处理,直接使用渲染函数
// Naive UI 的 NMenu 可以直接接受渲染函数作为 icon
let iconComponent = undefined;
if (item.icon) {
iconComponent = () => {
try {
const iconVNode = item.icon();
// 用 NIcon 包装以保持样式一致性
return h(NIcon, { size: 18 }, { 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;
}
return path;
});
// 处理菜单选择
// 注意:由于路由有 base 路径 '/dashboard/',不能使用 router="true"
// 需要手动处理路由导航
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: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
background: #fafafa;
/* 添加整体淡入淡出效果 */
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 用户信息区域 - 分层动画 */
.sidebar-header {
padding: 16px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
/* 使用平滑的缓动函数,避免抖动 */
transition: padding 0.25s 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: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.25s cubic-bezier(0.4, 0, 0.2, 1),
gap 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.user-info:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-info:active {
background: rgba(0, 0, 0, 0.06);
}
.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;
/* 移除 transform 过渡,避免抖动 */
}
.user-info-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
/* 平滑的淡入淡出效果 */
opacity: 1;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
max-width: 200px;
overflow: hidden;
}
/* 折叠状态下隐藏文字 */
.user-info-collapsed .user-info-text {
opacity: 0;
max-width: 0;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.brand-name {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
transition: opacity 0.2s ease;
}
.user-name {
font-size: 13px;
color: #666;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity 0.2s ease;
}
.chevron-icon {
font-size: 16px;
color: #8b8e95;
margin-left: auto;
flex-shrink: 0;
transition: color 0.2s ease;
}
.user-info:hover .chevron-icon {
color: #4a5568;
}
/* 菜单区域 */
.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: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.25s 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:active) {
background: rgba(0, 0, 0, 0.06);
}
: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;
/* 平滑过渡,不缩放 */
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
:deep(.app-sidebar-menu.n-menu--collapsed .n-menu-item-content:hover) {
background: rgba(0, 0, 0, 0.06);
}
: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.25s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 18px;
}
:deep(.app-sidebar-menu .n-icon svg) {
width: 18px;
height: 18px;
stroke-width: 1.2;
display: block;
/* 平滑过渡,避免抖动 */
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1),
height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 折叠状态下的图标 - 保持相同大小 */
:deep(.app-sidebar-menu.n-menu--collapsed .n-icon) {
font-size: 18px;
}
:deep(.app-sidebar-menu.n-menu--collapsed .n-icon svg) {
width: 18px;
height: 18px;
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>