20260810172235
This commit is contained in:
1 parent
c4891538a7
commit
ed5ab3885d
35 files changed
+1314
-653
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const preferredReducedMotion = usePreferredReducedMotion()
|
||||
const routeInstanceKey = computed(
|
||||
() => `${route.fullPath}:${appStore.getRouteRefreshKey(route.fullPath)}`,
|
||||
)
|
||||
@@ -59,7 +60,10 @@ function onRailMouseDown(e: MouseEvent) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarProvider class="group/sidebar bg-sidebar" :style="{ '--sidebar-width': '14rem' }">
|
||||
<SidebarProvider
|
||||
class="group/sidebar h-svh min-w-0 overflow-hidden bg-sidebar"
|
||||
:style="{ '--sidebar-width': '14rem' }"
|
||||
>
|
||||
<AppSidebar />
|
||||
|
||||
<!-- Drag rail: hidden when collapsed, follows sidebar width when expanded -->
|
||||
@@ -70,13 +74,17 @@ function onRailMouseDown(e: MouseEvent) {
|
||||
@mousedown="onRailMouseDown"
|
||||
/>
|
||||
|
||||
<SidebarInset class="m-2 ml-0 rounded-xl shadow z-10">
|
||||
<SidebarInset class="m-2 ml-0 min-w-0 overflow-hidden rounded-xl shadow z-10">
|
||||
<AppTopbar />
|
||||
<AppTabs />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4 p-4 pt-2">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-auto p-4 pt-2">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide" mode="out-in">
|
||||
<transition
|
||||
name="page"
|
||||
mode="out-in"
|
||||
:css="preferredReducedMotion !== 'reduce'"
|
||||
>
|
||||
<keep-alive :max="8">
|
||||
<component :is="Component" :key="routeInstanceKey" />
|
||||
</keep-alive>
|
||||
@@ -88,16 +96,19 @@ function onRailMouseDown(e: MouseEvent) {
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
.page-enter-active {
|
||||
transition:
|
||||
opacity 160ms ease-out,
|
||||
transform 160ms ease-out;
|
||||
}
|
||||
.slide-enter-from {
|
||||
.page-leave-active {
|
||||
transition: opacity 80ms ease-in;
|
||||
}
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
transform: translateY(4px);
|
||||
}
|
||||
.slide-leave-to {
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from '@/components/ui/sidebar'
|
||||
import type { NavItem } from '@/layouts/components/NavMenuItem.vue'
|
||||
|
||||
import {
|
||||
AudioWaveform, ChevronsUpDown, Command,
|
||||
GalleryVerticalEnd, LayoutGrid, Palette, Plus,
|
||||
GalleryVerticalEnd, LayoutGrid, Plus,
|
||||
} from "@lucide/vue"
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { getIcon } from '@/utils/lucideIcons'
|
||||
import { buildTree, sortTree } from '@/utils/tree'
|
||||
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
||||
@@ -22,6 +26,7 @@ const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
})
|
||||
|
||||
const { isMobile: isSidebarMobile } = useSidebar()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
// ---- teams ----
|
||||
const teams = [
|
||||
@@ -31,21 +36,66 @@ const teams = [
|
||||
]
|
||||
const activeTeam = ref(teams[0]!)
|
||||
|
||||
// ---- nav items ----
|
||||
const navMain = [
|
||||
{
|
||||
title: "工作台", url: "/dashboard", icon: LayoutGrid, isActive: true,
|
||||
},
|
||||
{
|
||||
title: "Demo 页面", url: "#", icon: Palette,
|
||||
items: [
|
||||
{ title: "表格示例", url: "/demo/table" },
|
||||
{ title: "表单示例", url: "/demo/form" },
|
||||
{ title: "卡片示例", url: "/demo/cards" },
|
||||
{ title: "设置示例", url: "/demo/settings" },
|
||||
],
|
||||
},
|
||||
]
|
||||
// ---- dynamic nav items ----
|
||||
const navMain = computed(() => {
|
||||
const items: NavItem[] = [
|
||||
{ title: "工作台", url: "/dashboard", icon: LayoutGrid },
|
||||
]
|
||||
|
||||
const menuModules = permissionStore.modules.filter(
|
||||
(m: any) =>
|
||||
m.b_canmenu === 1 &&
|
||||
m.b_canuse === 1 &&
|
||||
["directory", "page"].includes(m.b_module_type),
|
||||
)
|
||||
|
||||
if (!menuModules.length) return items
|
||||
|
||||
// Keep only modules whose parent (if any) is also in the menu
|
||||
const allowedIds = new Set(menuModules.map((m: any) => String(m.b_id)))
|
||||
const valid = menuModules.filter(
|
||||
(m: any) =>
|
||||
m.b_parent_id == null || allowedIds.has(String(m.b_parent_id)),
|
||||
)
|
||||
|
||||
const cloned: any[] = JSON.parse(JSON.stringify(valid))
|
||||
const tree = buildTree(cloned)
|
||||
sortTree(tree)
|
||||
|
||||
for (const node of tree) {
|
||||
const item = toNavItem(node)
|
||||
if (item) items.push(item)
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function toNavItem(node: any): NavItem | null {
|
||||
const isDir = node.b_module_type === "directory"
|
||||
const icon = isDir ? getIcon(node.b_icon) ?? undefined : undefined
|
||||
|
||||
const item: NavItem = {
|
||||
title: node.b_name || node.b_code,
|
||||
url: node.b_route ? String(node.b_route) : `#${node.b_code}`,
|
||||
}
|
||||
if (icon) item.icon = icon
|
||||
|
||||
if (!isDir && node.b_code) {
|
||||
item.moduleId = String(node.b_code)
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
item.items = node.children
|
||||
.map((c: any) => toNavItem(c))
|
||||
.filter(Boolean) as NavItem[]
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
permissionStore.load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, X } from "@lucide/vue"
|
||||
import { useResizeObserver } from "@vueuse/core"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { computed, nextTick, onMounted, onBeforeUnmount, ref, watch } from "vue"
|
||||
import { computed, nextTick, ref, watch } from "vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -15,12 +17,10 @@ const canScrollRight = ref(false)
|
||||
const activeKey = computed(() => route.fullPath)
|
||||
const tabs = computed(() => appStore.visitedTabs)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// Watch route changes to add tabs
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
async () => {
|
||||
const meta = route.meta as { title?: string }
|
||||
if (meta.title) {
|
||||
appStore.addVisitedTab({
|
||||
@@ -29,6 +29,9 @@ watch(
|
||||
closable: route.fullPath !== "/dashboard",
|
||||
})
|
||||
}
|
||||
await nextTick()
|
||||
revealActiveTab()
|
||||
updateScrollState()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -39,13 +42,7 @@ watch(() => appStore.visitedTabs.length, async () => {
|
||||
updateScrollState()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(updateScrollState)
|
||||
if (trackRef.value) resizeObserver.observe(trackRef.value)
|
||||
updateScrollState()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => resizeObserver?.disconnect())
|
||||
useResizeObserver(trackRef, updateScrollState)
|
||||
|
||||
function changeTab(key: string) {
|
||||
if (key !== route.fullPath) router.push(key)
|
||||
@@ -87,6 +84,15 @@ function scrollTabs(direction: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function revealActiveTab(behavior: ScrollBehavior = "smooth") {
|
||||
const track = trackRef.value
|
||||
if (!track) return
|
||||
const activeElement = Array.from(track.querySelectorAll<HTMLElement>("[data-tab-key]")).find(
|
||||
element => element.dataset.tabKey === activeKey.value,
|
||||
)
|
||||
activeElement?.scrollIntoView({ behavior, block: "nearest", inline: "center" })
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const track = trackRef.value
|
||||
if (!track || track.scrollWidth <= track.clientWidth) return
|
||||
@@ -102,22 +108,29 @@ function refreshTab(key: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="tabs.length" class="flex shrink-0 items-center h-9 gap-0.5 px-2 border-b border-border/40">
|
||||
<div
|
||||
v-if="tabs.length"
|
||||
class="flex h-10 w-full min-w-0 shrink-0 items-center gap-0.5 overflow-hidden px-2"
|
||||
>
|
||||
<!-- Left scroll button -->
|
||||
<button
|
||||
<Button
|
||||
v-if="overflowed"
|
||||
type="button"
|
||||
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
|
||||
aria-label="向左滚动页签"
|
||||
:disabled="!canScrollLeft"
|
||||
@click="scrollTabs(-1)"
|
||||
>
|
||||
<ChevronLeft class="size-3.5" />
|
||||
</button>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
|
||||
<!-- Tab track -->
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="flex flex-1 min-w-0 gap-1.5 overflow-x-auto overflow-y-visible scrollbar-none overscroll-contain"
|
||||
class="tab-track flex min-w-0 flex-1 gap-1.5 overflow-x-auto overflow-y-hidden overscroll-contain"
|
||||
role="tablist"
|
||||
@scroll.passive="updateScrollState"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
@@ -125,10 +138,14 @@ function refreshTab(key: string) {
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md px-2.5 text-xs font-medium transition-colors cursor-pointer border"
|
||||
:class="tab.key === activeKey
|
||||
role="tab"
|
||||
:data-tab-key="tab.key"
|
||||
:aria-selected="tab.key === activeKey"
|
||||
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md pl-2.5 text-xs font-medium transition-colors cursor-pointer border"
|
||||
:class="[tab.key === activeKey
|
||||
? 'bg-muted text-foreground border-border'
|
||||
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground'"
|
||||
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground',
|
||||
tab.closable ? 'pr-0' : 'pr-2.5']"
|
||||
@click="changeTab(tab.key)"
|
||||
@dblclick.prevent="refreshTab(tab.key)"
|
||||
>
|
||||
@@ -136,7 +153,7 @@ function refreshTab(key: string) {
|
||||
<!-- Close button -->
|
||||
<span
|
||||
v-if="tab.closable"
|
||||
class="grid size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
|
||||
class="flex flex-1 items-center justify-center self-stretch px-1 rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
|
||||
@click="closeTab(tab.key, $event)"
|
||||
>
|
||||
<X class="size-3" />
|
||||
@@ -145,14 +162,27 @@ function refreshTab(key: string) {
|
||||
</div>
|
||||
|
||||
<!-- Right scroll button -->
|
||||
<button
|
||||
<Button
|
||||
v-if="overflowed"
|
||||
type="button"
|
||||
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
|
||||
aria-label="向右滚动页签"
|
||||
:disabled="!canScrollRight"
|
||||
@click="scrollTabs(1)"
|
||||
>
|
||||
<ChevronRight class="size-3.5" />
|
||||
</button>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab-track {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
BadgeCheck, Bell, ChevronDown, CreditCard, Languages, LogOut, Moon, Palette, Sun,
|
||||
} from "@lucide/vue"
|
||||
import { computed, onMounted, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from "vue-sonner"
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Avatar, AvatarFallback, AvatarImage,
|
||||
} from '@/components/ui/avatar'
|
||||
@@ -20,12 +22,20 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const user = {
|
||||
name: "张三",
|
||||
email: "zhangsan@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
const user = computed(() => ({
|
||||
name: authStore.user?.name ?? '用户',
|
||||
email: authStore.user?.account ?? '',
|
||||
avatar: '',
|
||||
}))
|
||||
|
||||
function logout() {
|
||||
authStore.clearSession()
|
||||
toast.info('已退出登录')
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
const breadcrumbTitle = computed(() => (route.meta as { title?: string }).title ?? String(route.name ?? ''))
|
||||
@@ -88,14 +98,14 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
||||
|
||||
<!-- Right tools -->
|
||||
<div class="flex items-center gap-1 px-4">
|
||||
<Button variant="ghost" size="icon-sm" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
|
||||
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
|
||||
<Languages />
|
||||
</Button>
|
||||
|
||||
<!-- Color scheme grid -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon-sm" title="主题色切换">
|
||||
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="主题色切换">
|
||||
<Palette />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -113,11 +123,11 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button variant="ghost" size="icon-sm" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
|
||||
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
|
||||
<Sun v-if="store.isDark" />
|
||||
<Moon v-else />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" title="通知">
|
||||
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="通知">
|
||||
<Bell />
|
||||
</Button>
|
||||
<Separator orientation="vertical" class="mx-1 data-[orientation=vertical]:h-6" />
|
||||
@@ -125,7 +135,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
||||
<!-- User dropdown -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="gap-2 data-[state=open]:bg-accent">
|
||||
<Button variant="ghost" size="sm" class="gap-2 cursor-pointer data-[state=open]:bg-accent">
|
||||
<Avatar class="size-7 rounded-md">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-md text-xs">{{ user.name.slice(0, 1) }}</AvatarFallback>
|
||||
@@ -143,7 +153,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs text-muted-foreground">{{ user.email }}</span>
|
||||
<span class="truncate text-xs text-muted-foreground">{{ authStore.orgId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
@@ -154,7 +164,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
|
||||
<DropdownMenuItem><Bell /> 通知设置</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><LogOut /> 退出登录</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="logout"><LogOut /> 退出登录</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
import type { LucideIcon } from "@lucide/vue"
|
||||
import { ChevronRight } from "@lucide/vue"
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
export type NavItem = {
|
||||
@@ -21,6 +29,7 @@ export type NavItem = {
|
||||
icon?: LucideIcon
|
||||
isActive?: boolean
|
||||
items?: NavItem[]
|
||||
moduleId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -31,6 +40,9 @@ const props = withDefaults(defineProps<{
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { state } = useSidebar()
|
||||
|
||||
const isCollapsed = computed(() => state.value === 'collapsed')
|
||||
|
||||
function isActive(url: string) {
|
||||
if (url === '#') return false
|
||||
@@ -44,31 +56,73 @@ function hasActiveChild(item: NavItem): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Has children: render as collapsible -->
|
||||
<Collapsible v-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
|
||||
<!-- Root level: SidebarMenuItem + SidebarMenuButton -->
|
||||
<SidebarMenuItem v-if="props.depth === 0">
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<NavMenuItem
|
||||
v-for="child in item.items"
|
||||
:key="child.title"
|
||||
:item="child"
|
||||
:depth="1"
|
||||
/>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
<!-- Root-level parent with children -->
|
||||
<template v-if="item.items?.length && props.depth === 0">
|
||||
<!-- Collapsed mode: DropdownMenu -->
|
||||
<SidebarMenuItem v-if="isCollapsed">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="8" align="start">
|
||||
<template v-for="child in item.items" :key="child.title">
|
||||
<!-- Child with grandchildren: nested DropdownMenu -->
|
||||
<DropdownMenuItem v-if="child.items?.length" as-child>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground">
|
||||
<span>{{ child.title }}</span>
|
||||
<ChevronRight class="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="4" align="start">
|
||||
<DropdownMenuItem
|
||||
v-for="grandchild in child.items"
|
||||
:key="grandchild.title"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="grandchild.url" class="w-full">{{ grandchild.title }}</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuItem>
|
||||
<!-- Leaf child: direct link -->
|
||||
<DropdownMenuItem v-else as-child>
|
||||
<RouterLink :to="child.url" class="w-full">{{ child.title }}</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<!-- Sub level: SidebarMenuSubItem + SidebarMenuSubButton -->
|
||||
<SidebarMenuSubItem v-else>
|
||||
<!-- Expanded mode: Collapsible -->
|
||||
<Collapsible v-else as-child :default-open="hasActiveChild(item)" class="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<NavMenuItem
|
||||
v-for="child in item.items"
|
||||
:key="child.title"
|
||||
:item="child"
|
||||
:depth="1"
|
||||
/>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</template>
|
||||
|
||||
<!-- Sub-level parent with children -->
|
||||
<Collapsible v-else-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
|
||||
<SidebarMenuSubItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuSubButton>
|
||||
<span>{{ item.title }}</span>
|
||||
|
||||
Reference in new issue
Block a user