Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | <script setup>
import { computed, ref } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
/**
* 导航菜单项结构:
* @typedef {Object} NavItem
* @property {string} title 菜单标题
* @property {string} url 路由地址(目录项可传 "#" 占位)
* @property {Object} [icon] lucide 图标组件
* @property {NavItem[]} [items] 子菜单(有则为目录项,可展开/收起)
* @property {string} [moduleId] 页面模块编码
*/
const props = defineProps({
item: { type: Object, required: true },
depth: { type: Number, default: 0 },
})
const route = useRoute()
const appStore = useAppStore()
const collapsed = computed(() => appStore.sidebarCollapsed)
const isDir = computed(() => Boolean(props.item.items?.length))
const active = computed(() => !isDir.value && isActive(props.item.url))
function isActive(url) {
if (!url || url === '#') return false
return route.path === url || route.path.startsWith(url + '/')
}
function hasActiveChild(item) {
if (!item.items?.length) return false
return item.items.some((child) => isActive(child.url) || hasActiveChild(child))
}
// 目录项展开状态:默认展开包含激活子项的父级
const open = ref(isDir.value && hasActiveChild(props.item))
function onToggle() {
// 折叠态下点击任意菜单项:展开侧栏并打开该目录
if (collapsed.value) {
appStore.setSidebar(false)
open.value = true
return
}
open.value = !open.value
}
</script>
<template>
<!-- 目录项:可展开/收起 -->
<div v-if="isDir" class="fms-nav-item fms-nav-dir" :class="{ 'is-open': open }">
<button
class="fms-nav-btn"
type="button"
:title="collapsed ? item.title : undefined"
:aria-expanded="open"
@click="onToggle"
>
<span v-if="item.icon" class="fms-nav-icon"><component :is="item.icon" /></span>
<span v-if="!collapsed" class="fms-nav-title">{{ item.title }}</span>
<ChevronRight v-if="!collapsed" class="fms-nav-caret" aria-hidden="true" />
</button>
<div v-if="open && !collapsed" class="fms-nav-sub">
<NavMenuItem
v-for="child in item.items"
:key="child.title"
:item="child"
:depth="depth + 1"
/>
</div>
</div>
<!-- 叶子项:路由链接 -->
<RouterLink
v-else
class="fms-nav-item fms-nav-link"
:class="{ 'is-active': active }"
:to="item.url"
:title="collapsed ? item.title : undefined"
>
<span v-if="item.icon" class="fms-nav-icon"><component :is="item.icon" /></span>
<span v-else-if="collapsed" class="fms-nav-icon fms-nav-fallback">{{
item.title.slice(0, 1)
}}</span>
<span v-if="!collapsed" class="fms-nav-title">{{ item.title }}</span>
</RouterLink>
</template>
<style lang="scss" scoped>
/* 菜单项基础布局:叶子链接与目录容器共用(hover 背景单独定义在 link/btn 上) */
.fms-nav-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 8px;
border-radius: 6px;
color: var(--fms-text);
font-size: var(--fms-font-size);
line-height: 20px;
text-decoration: none;
cursor: pointer;
transition:
background 150ms ease,
color 150ms ease,
box-shadow 150ms ease;
}
/* 叶子链接 hover:仅自身高亮 */
.fms-nav-link:hover {
background: var(--fms-secondary);
color: var(--fms-text);
}
/* 目录项容器:垂直堆叠按钮与子菜单。
自身不渲染背景 / hover / 内边距,避免鼠标移入子菜单时整个父级连带高亮 */
.fms-nav-dir {
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 0;
cursor: default;
}
/* 目录项按钮化 */
.fms-nav-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fms-text);
font-family: inherit;
font-size: var(--fms-font-size);
line-height: 20px;
text-align: left;
cursor: pointer;
transition:
background 150ms ease,
color 150ms ease;
&:hover {
background: var(--fms-secondary);
color: var(--fms-text);
}
&:focus-visible {
outline: 2px solid var(--fms-primary);
outline-offset: -2px;
}
}
.fms-nav-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
color: inherit;
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
}
.fms-nav-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fms-nav-caret {
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--fms-text-secondary);
transition: transform 150ms ease;
}
/* 折叠态下无图标叶子项:显示标题首字符 */
.fms-nav-fallback {
font-size: 14px;
font-weight: 500;
color: var(--fms-text);
}
/* 目录展开:仅箭头旋转 90°(对齐 old2 Collapsible) */
.fms-nav-dir.is-open > .fms-nav-btn .fms-nav-caret {
transform: rotate(90deg);
}
/* 子菜单:mx-3.5 border-l px-2.5(对齐 SidebarMenuSub) */
.fms-nav-sub {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: 14px;
padding-left: 10px;
border-left: 1px solid var(--fms-border);
}
/* 子菜单项:h-7 略矮(对齐 SidebarMenuSubButton) */
.fms-nav-sub .fms-nav-item,
.fms-nav-sub .fms-nav-btn {
min-height: 28px;
}
/* 激活高亮:白底 + shadow-sm + 深色文字(对齐 data-[active=true]) */
.fms-nav-link.is-active {
background: var(--fms-card);
color: var(--fms-text);
font-weight: 500;
box-shadow: var(--fms-shadow);
}
/* ---- 折叠态:仅图标,居中展示(对齐 size-8 icon 模式) ---- */
/* 折叠守卫包在祖先 .fms-sidebar-body.is-collapsed 下;
* scoped 下祖先选择器照常匹配,仅末级 .fms-nav-* 带本组件 data-v */
.fms-sidebar-body.is-collapsed .fms-nav-item,
.fms-sidebar-body.is-collapsed .fms-nav-btn {
justify-content: center;
padding: 0;
}
</style>
|