543 lines
14 KiB
Vue
543 lines
14 KiB
Vue
<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/sites,route.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.push,Vue 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;
|
||
}
|
||
|
||
/* 菜单区域 */
|
||
.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>
|