左边栏基于naive ui重构

This commit is contained in:
jingrow 2025-12-28 01:12:59 +08:00
parent a61e776d3a
commit 4f27721cd1
2 changed files with 281 additions and 83 deletions

View File

@ -1,14 +1,12 @@
<template>
<div class="relative flex h-full flex-col">
<div class="h-full flex-1">
<div class="flex h-full">
<div
v-if="!isSignupFlow && !$isMobile && !isHideSidebar"
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
>
<AppSidebar v-if="$session.user" />
</div>
<div class="w-full overflow-auto" id="scrollContainer">
<n-layout class="h-full" has-sider>
<AppSidebar
v-if="!isSignupFlow && !$isMobile && !isHideSidebar && $session.user"
v-model:collapsed="sidebarCollapsed"
/>
<n-layout class="h-full">
<div class="w-full h-full overflow-auto" id="scrollContainer">
<MobileNav
v-if="!isSignupFlow && $isMobile && !isHideSidebar && $session.user"
/>
@ -27,15 +25,16 @@
</div>
<router-view />
</div>
</div>
</div>
</n-layout>
</n-layout>
<Toaster position="top-right" />
<component v-for="dialog in dialogs" :is="dialog" :key="dialog.id" />
</div>
</template>
<script setup>
import { defineAsyncComponent, computed, watch, ref, provide } from 'vue';
import { defineAsyncComponent, computed, watch, ref, provide, onMounted } from 'vue';
import { NLayout } from 'naive-ui';
import { Toaster } from 'vue-sonner';
import { dialogs } from './utils/components';
import { useRoute } from 'vue-router';
@ -52,6 +51,20 @@ const MobileNav = defineAsyncComponent(
const route = useRoute();
const team = getTeam();
//
const sidebarCollapsed = ref(false);
//
onMounted(() => {
const checkMobile = () => {
if (window.innerWidth < 768) {
sidebarCollapsed.value = true;
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
});
const isHideSidebar = computed(() => {
const alwaysHideSidebarRoutes = [
'Site Login',

View File

@ -1,98 +1,283 @@
<template>
<div
class="relative flex min-h-screen w-[220px] flex-col border-r bg-gray-50"
<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 class="p-2">
<Dropdown
:options="[
{
label: '切换团队',
icon: 'command',
condition: () =>
$team?.pg?.valid_teams?.length > 1 || $team?.pg?.is_desk_user,
onClick: () => (showTeamSwitcher = true)
},
{
label: '退出登录',
icon: 'log-out',
onClick: $session.logout.submit
}
]"
>
<template v-slot="{ open }">
<button
class="flex w-[204px] items-center rounded-md px-2 py-2 text-left"
:class="open ? 'bg-white shadow-sm' : 'hover:bg-gray-200'"
<div class="flex h-full flex-col">
<!-- 用户信息区域 -->
<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="mb-1 h-8 w-8 shrink-0 rounded" />
<div class="ml-2 flex flex-1 flex-col overflow-hidden">
<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>
<Tooltip :text="$team?.pg?.user || null">
<div
class="mt-1 hidden overflow-hidden text-ellipsis whitespace-nowrap pb-1 text-sm leading-none text-gray-700 sm:inline"
>
{{ $team?.get.loading ? '加载中...' : $team?.pg?.user }}
</div>
</Tooltip>
<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>
<FeatherIcon
name="chevron-down"
class="ml-auto h-5 w-5 text-gray-700"
</div>
</n-dropdown>
</div>
<!-- 导航菜单 -->
<div class="flex-1 overflow-auto">
<NavigationItems>
<template v-slot="{ navigation }">
<n-menu
:collapsed="collapsed"
:collapsed-width="64"
:options="convertToMenuOptions(navigation)"
:value="activeKey"
:router="false"
@update:value="handleMenuSelect"
/>
</button>
</template>
</Dropdown>
</div>
<nav class="px-2">
<NavigationItems>
<template v-slot="{ navigation }">
<template v-for="(item, i) in navigation" :key="item.name">
<AppSidebarItemGroup v-if="item.children" :item="item" />
<AppSidebarItem class="mt-0.5" v-else :item="item" />
</template>
</template>
</NavigationItems>
</nav>
<!-- TODO: update component name after dashboard-beta merges -->
</NavigationItems>
</div>
</div>
<!-- 切换团队对话框 -->
<SwitchTeamDialog2 v-model="showTeamSwitcher" />
</div>
</n-layout-sider>
</template>
<script>
import { defineAsyncComponent } from 'vue';
import AppSidebarItem from './AppSidebarItem.vue';
import { Tooltip } from 'jingrow-ui';
import { defineAsyncComponent, computed, ref, h, getCurrentInstance } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NLayoutSider, NMenu, NDropdown, NTooltip } from 'naive-ui';
import NavigationItems from './NavigationItems.vue';
import AppSidebarItemGroup from './AppSidebarItemGroup.vue';
import JLogo from '@/components/icons/JLogo.vue';
export default {
name: 'AppSidebar',
components: {
AppSidebarItem,
AppSidebarItemGroup,
NLayoutSider,
NMenu,
NDropdown,
NTooltip,
NavigationItems,
JLogo,
SwitchTeamDialog2: defineAsyncComponent(() =>
import('./SwitchTeamDialog.vue')
),
Tooltip,
NavigationItems
},
data() {
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)) return [];
return items
.filter((item) => item.condition !== false)
.map((item) => {
// item.icon h()
// Naive UI n-menu icon
let iconComponent = undefined;
if (item.icon) {
// item.icon 使
// VNode
iconComponent = item.icon;
}
// 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 handleCollapse = () => {
emit('update:collapsed', true);
};
//
const handleExpand = () => {
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: false
showTeamSwitcher,
collapsed,
dropdownOptions,
activeKey,
teamUserText,
handleDropdownSelect,
handleMenuSelect,
handleCollapse,
handleExpand,
convertToMenuOptions,
};
},
methods: {
support() {
window.open('https://jingrow.com/support', '_blank');
},
feedback() {
window.open(
'https://jingrow.com/feedback/new',
'_blank'
);
}
}
};
</script>