375 lines
11 KiB
Vue
375 lines
11 KiB
Vue
<template>
|
||
<n-layout-sider
|
||
:collapsed="collapsed"
|
||
:collapsed-width="64"
|
||
:width="220"
|
||
:show-trigger="true"
|
||
:collapsible="true"
|
||
:trigger-style="{ position: 'absolute', right: '-20px' }"
|
||
:collapsed-trigger-style="{ position: 'absolute', right: '-20px' }"
|
||
class="h-screen"
|
||
@collapse="handleCollapse"
|
||
@expand="handleExpand"
|
||
>
|
||
<div style="display: block; height: 100%;">
|
||
<!-- 用户信息区域 -->
|
||
<div class="p-2">
|
||
<n-dropdown
|
||
:options="dropdownOptions"
|
||
:show-arrow="true"
|
||
trigger="click"
|
||
@select="handleDropdownSelect"
|
||
>
|
||
<div
|
||
class="flex cursor-pointer items-center rounded-md px-2 py-2 transition-colors hover:bg-gray-200"
|
||
:class="{ 'justify-center': collapsed }"
|
||
>
|
||
<JLogo class="h-8 w-8 shrink-0 rounded" />
|
||
<div
|
||
v-if="!collapsed"
|
||
class="ml-2 flex flex-1 flex-col overflow-hidden"
|
||
>
|
||
<div class="text-base font-medium leading-none text-gray-900">
|
||
今果 Jingrow
|
||
</div>
|
||
<n-tooltip>
|
||
<template #trigger>
|
||
<div
|
||
class="mt-1 overflow-hidden text-ellipsis whitespace-nowrap pb-1 text-sm leading-none text-gray-700"
|
||
>
|
||
{{ teamUserText }}
|
||
</div>
|
||
</template>
|
||
{{ teamUserText }}
|
||
</n-tooltip>
|
||
</div>
|
||
</div>
|
||
</n-dropdown>
|
||
</div>
|
||
|
||
<!-- 导航菜单 -->
|
||
<div class="flex-1 overflow-auto">
|
||
<NavigationItems>
|
||
<template v-slot="{ navigation }">
|
||
<n-menu
|
||
:collapsed="collapsed"
|
||
:collapsed-width="64"
|
||
:collapsed-icon-size="24"
|
||
:options="convertToMenuOptions(navigation)"
|
||
:value="activeKey"
|
||
:router="false"
|
||
@update:value="handleMenuSelect"
|
||
class="app-sidebar-menu"
|
||
/>
|
||
</template>
|
||
</NavigationItems>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 切换团队对话框 -->
|
||
<SwitchTeamDialog2 v-model="showTeamSwitcher" />
|
||
</n-layout-sider>
|
||
</template>
|
||
|
||
<script>
|
||
import { defineAsyncComponent, computed, ref, h, getCurrentInstance } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { NLayoutSider, NMenu, NDropdown, NTooltip, NIcon } from 'naive-ui';
|
||
import NavigationItems from './NavigationItems.vue';
|
||
import JLogo from '@/components/icons/JLogo.vue';
|
||
|
||
export default {
|
||
name: 'AppSidebar',
|
||
components: {
|
||
NLayoutSider,
|
||
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) => {
|
||
console.log(`[AppSidebar] Processing "${item.name}":`, {
|
||
hasIcon: !!item.icon,
|
||
iconType: typeof item.icon,
|
||
collapsed: collapsed.value
|
||
});
|
||
// 处理图标:item.icon 是一个函数,返回 h() 创建的组件
|
||
// Naive UI 的 n-menu 需要 icon 是一个渲染函数
|
||
let iconComponent = undefined;
|
||
if (item.icon) {
|
||
iconComponent = () => {
|
||
try {
|
||
const iconVNode = item.icon();
|
||
// 检查图标 VNode 的实际结构
|
||
console.log(`[AppSidebar] Icon render for "${item.name}":`, {
|
||
hasVNode: !!iconVNode,
|
||
vnodeType: iconVNode?.type,
|
||
vnodeTypeName: iconVNode?.type?.name || iconVNode?.type?.__name || iconVNode?.type,
|
||
vnodeProps: iconVNode?.props,
|
||
vnodeChildren: iconVNode?.children,
|
||
shapeFlag: iconVNode?.shapeFlag,
|
||
collapsed: collapsed.value
|
||
});
|
||
|
||
// 如果图标 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 包装
|
||
const wrapped = h(NIcon, null, { default: () => iconVNode });
|
||
console.log(`[AppSidebar] Wrapped icon for "${item.name}":`, {
|
||
hasWrapped: !!wrapped,
|
||
wrappedType: wrapped?.type,
|
||
wrappedChildren: wrapped?.children,
|
||
wrappedProps: wrapped?.props
|
||
});
|
||
return wrapped;
|
||
} 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 handleCollapse = () => {
|
||
console.log('[AppSidebar] handleCollapse called');
|
||
emit('update:collapsed', true);
|
||
};
|
||
|
||
// 处理展开
|
||
const handleExpand = () => {
|
||
console.log('[AppSidebar] handleExpand called');
|
||
emit('update:collapsed', false);
|
||
};
|
||
|
||
// 团队用户文本
|
||
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,
|
||
handleCollapse,
|
||
handleExpand,
|
||
convertToMenuOptions,
|
||
};
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 全局覆盖,确保优先级最高 */
|
||
/* 确保菜单图标在折叠状态下正确显示 */
|
||
/* 注意:不要覆盖 Naive UI 的默认 display 样式,只调整必要的属性 */
|
||
|
||
:deep(.app-sidebar-menu.n-menu--collapsed .n-menu-item-content) {
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 确保图标 SVG 可见 */
|
||
:deep(.app-sidebar-menu .n-icon svg) {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
|
||
/* 修复 n-layout-sider 的 flex 布局导致图标不显示的问题 */
|
||
/* 根源:Naive UI 的 .n-layout-sider 默认是 display: flex,这会导致内部图标无法正确渲染 */
|
||
/* 解决方案:直接覆盖 .n-layout-sider 的 display 属性,使用更高优先级的选择器 */
|
||
</style>
|
||
|
||
<style>
|
||
/* 全局样式,不使用 scoped,确保能覆盖 Naive UI 的默认样式 */
|
||
/* 修复:覆盖 .n-layout-sider 的 display: flex,这是导致图标不显示的根本原因 */
|
||
.n-layout-sider {
|
||
display: block !important;
|
||
}
|
||
|
||
.n-layout-sider .n-layout-sider-scroll-container {
|
||
display: block !important;
|
||
}
|
||
|
||
/* 确保所有内部容器都是 block,避免 flex 布局影响图标 */
|
||
.n-layout-sider > * {
|
||
display: block !important;
|
||
}
|
||
</style>
|
||
|
||
<style scoped>
|
||
</style> |