You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Vue侧边栏Dropdown Menu联动控制需求实现求助

Vue.js 侧边栏下拉菜单行为控制问题

需求

  • 打开新的非active状态下拉菜单时,已展开的非active下拉自动折叠
  • active状态的下拉菜单不会自动折叠,仅支持手动开关
  • 手动关闭active下拉后,已展开的非active下拉保持展开

说明

  • active state:当前访问路由处于:active状态
  • isOpen:控制下拉菜单展开/折叠的状态变量
  • 组件分工:
    • sidebarContent.vue:侧边栏所有菜单的容器
    • sidebarCollapsibleItem.vue:处理下拉菜单的子项链接
    • sidebarCollapsible.vue:下拉菜单的容器组件,实现展开/折叠逻辑
    • sidebarLink.vue:无下拉的菜单链接组件

当前代码

sidebarCollapsible.vue

<script setup>
import { ref, watch } from 'vue';
import { sidebarState } from '@/Composables';
import SidebarLink from '@/Components/Sidebar/SidebarLink.vue';
import { EmptyCircleIcon } from '@/Components/Icons/Outline';

const props = defineProps({
    title: {
        type: String,
    },
    icon: {
        required: false,
    },
    active: {
        type: Boolean,
        default: false,
    },
});

const { active } = props;
const isOpen = ref(active);

const beforeEnter = (el) => {
    el.style.maxHeight = `0px`;
};
const enter = (el) => {
    el.style.maxHeight = `${el.scrollHeight}px`;
};
const beforeLeave = (el) => {
    el.style.maxHeight = `${el.scrollHeight}px`;
};
const leave = (el) => {
    el.style.maxHeight = `0px`;
};

watch(() => sidebarState.isHovered, (sideHover) => {
    if (!sideHover && !sidebarState.isOpen && !active) {
        isOpen.value = false;
    };
});
</script>

<template>
    <div class="relative">
         <SidebarLink @click="isOpen = !isOpen" :title="title" :active="active">
            <template #icon>
                <slot name="icon">
                    <EmptyCircleIcon aria-hidden="true" class="flex-shrink-0 w-6 h-6" />
                </slot>
            </template>

            <template #arrow>
                <span v-show="sidebarState.isOpen || sidebarState.isHovered" aria-hidden="true" class="relative block w-6 h-6 ml-auto">
                    <span :class="['absolute right-[9px] bg-gray-400 mt-[-5px] h-2 w-[2px] top-1/2 transition-all duration-200', {'-rotate-45': isOpen, 'rotate-45': !isOpen}]"></span>
                    <span :class="['absolute left-[9px] bg-gray-400 mt-[-5px] h-2 w-[2px] top-1/2 transition-all duration-200', {'rotate-45': isOpen, '-rotate-45': !isOpen}]"></span>
                </span>
            </template>
        </SidebarLink>

        <transition 
            @before-enter="beforeEnter"
            @enter="enter"
            @before-leave="beforeLeave"
            @leave="leave"
            appear>

            <div v-show="isOpen && (sidebarState.isOpen || sidebarState.isHovered)" class="overflow-hidden transition-all duration-200">
                <ul class="relative px-0 pt-2 pb-0 ml-5 before:w-0 before:block before:absolute before:inset-y-0 before:left-0 before:border-l-2 before:border-l-gray-200 dark:before:border-l-gray-600">
                    <slot />
                </ul>
            </div>
        </transition>
    </div>
</template>

sidebarCollapsibleItem.vue

<script setup>
import { defineProps } from 'vue';
import { Link } from '@inertiajs/vue3';

const props = defineProps({
    href: String,
    title: String,
    active: {
        type: Boolean,
        default: false,
    },
    external: {
        type: Boolean,
        default: false,
    },
});

const { external } = props;

const Tag = external ? 'a' : Link;
</script>

<template>
    <li
        :class="[
            'relative leading-8 m-0 pl-6',
            'before:block before:w-4 before:h-0 before:absolute before:left-0 before:top-4 before:border-t-2 before:border-t-gray-200 before:-mt-0.5',
            'last:before:bg-white last:before:h-auto last:before:top-4 last:before:bottom-0',
            'dark:last:before:bg-dark-eval-1 dark:before:border-t-gray-600',
        ]"
    >
        <component
            :is="Tag"
            :href="href"
            v-bind="$attrs"
            :class="[
                'transition-colors hover:text-gray-900 dark:hover:text-gray-100',
                {
                    'text-purple-500 dark:text-purple-500 hover:text-purple-600 dark:hover:text-purple-600': active,
                    'text-gray-500 dark:text-gray-400': !active,
                },
            ]"
        >
            {{ title }}
        </component>
    </li>
</template>

sidebarContent.vue

<script setup>
import PerfrectScrollbar from '@/Components/PerfectScrollbar';
import SidebarLink from '@/Components/Sidebar/SidebarLink.vue';
import { DashboardIcon } from '@/Components/Icons/Outline';
import { UserGroupIcon, BuildingOffice2Icon } from '@heroicons/vue/24/outline';
import SidebarCollapsible from '@/Components/Sidebar/SidebarCollapsible.vue';
import SidebarCollapsibleItem from '@/Components/Sidebar/SidebarCollapsibleItem.vue';
</script>

<template>
    <PerfrectScrollbar
        tagname="nav"
        aria-label="main"
        class="relative flex flex-col flex-1 max-h-full gap-4 px-3"
    >
        <SidebarLink
            title="Dashboard"
            :href="route('dashboard')"
            :active="route().current('dashboard')"
        >
            <template #icon>
                <DashboardIcon
                    class="flex-shrink-0 w-6 h-6"
                    aria-hidden="true"
                />
            </template>
        </SidebarLink>

        <SidebarCollapsible
            title="Management"
            :active="route().current('users.*') || route().current('teams.*') || route().current('divisions.*') || route().current('departments.*')"
        >
            <template #icon>
                <UserGroupIcon
                class="flex-shrink-0 w-6 h-6"
                aria-hidden="true"
                />
            </template>

            <SidebarCollapsibleItem
                title="Users"
                :href="route('users.index')"
                :active="route().current('users.*')"
            />

            <SidebarCollapsibleItem
                title="Roles"
                :href="route('teams.index')"
                :active="route().current('#')"
            />

            <SidebarCollapsibleItem
                :href="route('teams.index')"
                title="Teams"
                :active="route().current('teams.*')"
            />

            <SidebarCollapsibleItem
                title="Departments"
                :href="route('departments.index')"
                :active="route().current('departments.*')"
            />

            <SidebarCollapsibleItem
                title="Divisions"
                :href="route('divisions.index')"
                :active="route().current('divisions.*')"
            />
        </SidebarCollapsible>

        <SidebarCollapsible
            title="Locations"
            :active="route().current('externals.*') || route().current('internals.*')"
        >
            <template #icon>
                <BuildingOffice2Icon
                class="flex-shrink-0 w-6 h-6"
                aria-hidden="true"
                />
            </template>

            <SidebarCollapsibleItem
                title="Internals"
                :href="route('internals.index')"
                :active="route().current('internals.*')"
            />

            <SidebarCollapsibleItem
                title="Externals"
                :href="route('externals.index')"
                :active="route().current('externals.*')"
            />
        </SidebarCollapsible>

        <SidebarCollapsible
            title="test"
            :active="route().current('api-tokens.*')"
        >
            <template #icon>
                <BuildingOffice2Icon
                class="flex-shrink-0 w-6 h-6"
                aria-hidden="true"
                />
            </template>

            <SidebarCollapsibleItem
                title="Internals"
                :href="route('api-tokens.index')"
                :active="route().current('user.*')"
            />

            <SidebarCollapsibleItem
                title="Externals"
                :href="route('api-tokens.index')"
                :active="route().current('api-tokens.*')"
            />

        </SidebarCollapsible>

    </PerfrectScrollbar>
</template>

解决方案

核心思路

将下拉菜单的状态管理提升到父组件sidebarContent.vue,统一控制非active下拉的展开状态,同时保留active下拉的独立手动控制能力。

修改步骤

1. 更新sidebarContent.vue

在父组件中维护所有非active下拉的状态,处理下拉点击事件的折叠逻辑:

<script setup>
import PerfrectScrollbar from '@/Components/PerfectScrollbar';
import SidebarLink from '@/Components/Sidebar/SidebarLink.vue';
import { DashboardIcon } from '@/Components/Icons/Outline';
import { UserGroupIcon, BuildingOffice2Icon } from '@heroicons/vue/24/outline';
import SidebarCollapsible from '@/Components/Sidebar/SidebarCollapsible.vue';
import SidebarCollapsibleItem from '@/Components/Sidebar/SidebarCollapsibleItem.vue';
import { ref } from 'vue';

// 维护非active下拉的展开状态,key为下拉标题
const nonActiveDropdowns = ref({
  'Management': false,
  'Locations': false,
  'test': false,
});

// 处理下拉菜单点击事件
const handleDropdownClick = (title, isActive, currentIsOpen) => {
  if (isActive) return;
  
  // 非active下拉:展开时关闭其他所有非active下拉,关闭时仅处理自身
  nonActiveDropdowns.value[title] = !currentIsOpen;
  if (!currentIsOpen) {
    Object.keys(nonActiveDropdowns.value).forEach(key => {
      if (key !== title) nonActiveDropdowns.value[key] = false;
    });
  }
};
</script>

<template>
  <PerfrectScrollbar
    tagname="nav"
    aria-label="main"
    class="relative flex flex-col flex-1 max-h-full gap-4 px-3"
  >
    <SidebarLink
      title="Dashboard"
      :href="route('dashboard')"
      :active="route().current('dashboard')"
    >
      <template #icon>
        <DashboardIcon
          class="flex-shrink-0 w-6 h-6"
          aria-hidden="true"
        />
      </template>
    </SidebarLink>

    <SidebarCollapsible
      title="Management"
      :active="route().current('users.*') || route().current('teams.*') || route().current('divisions.*') || route().current('departments.*')"
      :isOpen="nonActiveDropdowns['Management']"
      @toggle="handleDropdownClick('Management', $event.active, $event.isOpen)"
    >
      <!-- 子项内容保持不变 -->
      <template #icon>
        <UserGroupIcon class="flex-shrink-0 w-6 h-6" aria-hidden="true" />
      </template>
      <SidebarCollapsibleItem title="Users" :href="route('users.index')" :active="route().current('users.*')" />
      <SidebarCollapsibleItem title="Roles" :href="route('teams.index')" :active="route().current('#')" />
      <SidebarCollapsibleItem title="Teams" :href="route('teams.index')" :active="route().current('teams.*')" />
      <SidebarCollapsibleItem title="Departments" :href="route('departments.index')" :active="route().current('departments.*')" />
      <SidebarCollapsibleItem title="Divisions" :href="route('divisions.index')" :active="route().current('divisions.*')" />
    </SidebarCollapsible>

    <SidebarCollapsible
      title="Locations"
      :active="route().current('externals.*') || route().current('internals.*')"
      :isOpen="nonActiveDropdowns['Locations']"
      @toggle="handleDropdownClick('Locations', $event.active, $event.isOpen)"
    >
      <!-- 子项内容保持不变 -->
      <template #icon>
        <BuildingOffice2Icon class="flex-shrink-0 w-6 h-6" aria-hidden="true" />
      </template>
      <SidebarCollapsibleItem title="Internals" :href="route('internals.index')" :active="route().current('internals.*')" />
      <SidebarCollapsibleItem title="Externals" :href="route('externals.index')" :active="route().current('externals.*')" />
    </SidebarCollapsible>

    <SidebarCollapsible
      title="test"
      :active="route().current('api-tokens.*')"
      :isOpen="nonActiveDropdowns['test']"
      @toggle="handleDropdownClick('test', $event.active, $event.isOpen)"
    >
      <!-- 子项内容保持不变 -->
      <template #icon>
        <BuildingOffice2Icon class="flex-shrink-0 w-6 h-6" aria-hidden="true" />
      </template>
      <SidebarCollapsibleItem title="Internals" :href="route('api-tokens.index')" :active="route().current('user.*')" />
      <SidebarCollapsibleItem title="Externals" :href="route('api-tokens.index')" :active="route().current('api-tokens.*')" />
    </SidebarCollapsible>
  </PerfrectScrollbar>
</template>

2. 更新sidebarCollapsible.vue

接收父组件传递的状态,触发toggle事件,并区分active与非active下拉的状态逻辑:

<script setup>
import { ref, watch, computed } from 'vue';
import { sidebarState } from '@/Composables';
import SidebarLink from '@/Components/Sidebar/SidebarLink.vue';
import { EmptyCircleIcon } from '@/Components/Icons/Outline';

const props = defineProps({
    title: { type: String },
    icon: { required: false },
    active: { type: Boolean, default: false },
    isOpen: { type: Boolean, default: false },
});

const emit = defineEmits(['toggle']);

// active下拉使用本地独立状态,非active使用父组件传递的状态
const localIsOpen = ref(props.active);
const currentIsOpen = computed(() => props.active ? localIsOpen.value : props.isOpen);

// 动画钩子保持不变
const beforeEnter = (el) => el.style.maxHeight = `0px`;
const enter = (el) => el.style.maxHeight = `${el.scrollHeight}px`;
const beforeLeave = (el) => el.style.maxHeight = `${el.scrollHeight}px`;
const leave = (el) => el.style.maxHeight = `0px`;

// 处理点击事件
const handleClick = () => {
    const newState = props.active ? !localIsOpen.value : !props.isOpen;
    if (props.active) localIsOpen.value = newState;
    emit('toggle', { active: props.active, isOpen: newState });
};

// 侧边栏收起/未hover时,关闭非active下拉
watch(() => sidebarState.isHovered, (sideHover) => {
    if (!sideHover && !sidebarState.isOpen && !props.active) {
        emit('toggle', { active: props.active, isOpen: false });
    }
});
</script>

<template>
    <div class="relative">
         <SidebarLink @click="handleClick" :title="title" :active="active">
            <template #icon>
                <slot name="icon">
                    <EmptyCircleIcon aria-hidden="true" class="flex-shrink-0 w-6 h-6" />
                </slot>
            </template>

            <
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.25 00:05:29