271 lines
8.2 KiB
Vue
271 lines
8.2 KiB
Vue
<template>
|
|
<nav aria-label="sidebar navigation">
|
|
<v-list
|
|
role="none"
|
|
v-model:opened="openedModel"
|
|
color="primary"
|
|
density="compact"
|
|
prepend-gap="8"
|
|
>
|
|
<template v-for="item in menuItems" :key="item.path ?? item.title">
|
|
<v-list-group
|
|
v-if="item.subItems?.length"
|
|
:id="getGroupId(item)"
|
|
:value="getGroupValue(item)"
|
|
>
|
|
<template #activator="{ props: activatorProps }">
|
|
<v-list-item
|
|
v-bind="isShrink ? undefined : activatorProps"
|
|
role="button"
|
|
:aria-selected="undefined"
|
|
:class="{ 'px-0': isShrink }"
|
|
:link="isNavigable(item) && !!item.path"
|
|
:to="isNavigable(item) ? item.path : undefined"
|
|
@click="emitSelect(item)"
|
|
>
|
|
<template #prepend>
|
|
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
|
|
<v-btn
|
|
v-if="isShrink && !item.icon"
|
|
class=""
|
|
rounded
|
|
size="36"
|
|
spaced="start"
|
|
variant="text"
|
|
>{{ item.title?.charAt(0) }}</v-btn
|
|
>
|
|
</template>
|
|
<template #title>
|
|
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
|
</template>
|
|
<template #append>
|
|
<v-chip
|
|
v-if="!isShrink && getItemCount(item) > 0"
|
|
class="menu-count"
|
|
color="secondary"
|
|
size="x-small"
|
|
variant="tonal"
|
|
>
|
|
{{ getItemCount(item) }}
|
|
</v-chip>
|
|
</template>
|
|
</v-list-item>
|
|
</template>
|
|
|
|
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
|
|
<v-list-group
|
|
v-if="subItem.subItems?.length"
|
|
:id="getGroupId(subItem, getGroupId(item))"
|
|
:value="getGroupValue(subItem, getGroupValue(item))"
|
|
>
|
|
<template #activator="{ props: subProps }">
|
|
<v-list-item
|
|
v-bind="subProps"
|
|
role="button"
|
|
:aria-selected="undefined"
|
|
:link="isNavigable(subItem)"
|
|
:prepend-icon="subItem.icon || mdiMenuRight"
|
|
:to="isNavigable(subItem) ? subItem.path : undefined"
|
|
@click="emitSelect(subItem)"
|
|
>
|
|
<template #title>
|
|
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
|
</template>
|
|
<template #append>
|
|
<v-chip
|
|
v-if="getItemCount(subItem) > 0"
|
|
class="menu-count"
|
|
color="secondary"
|
|
size="x-small"
|
|
variant="tonal"
|
|
>
|
|
{{ getItemCount(subItem) }}
|
|
</v-chip>
|
|
</template>
|
|
</v-list-item>
|
|
</template>
|
|
|
|
<v-list-item
|
|
v-for="subSubItem in subItem.subItems"
|
|
:key="subSubItem.path ?? subSubItem.title"
|
|
:link="!!subSubItem.path"
|
|
:prepend-icon="mdiCircleSmall"
|
|
:to="subSubItem.path"
|
|
@click="emitSelect(subSubItem)"
|
|
>
|
|
<template #title>
|
|
<v-tooltip location="end" :text="subSubItem.title" :aria-label="subSubItem.title">
|
|
<template #activator="{ props: tooltipProps }">
|
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
|
|
subSubItem.title
|
|
}}</span>
|
|
</template>
|
|
</v-tooltip>
|
|
</template>
|
|
</v-list-item>
|
|
</v-list-group>
|
|
|
|
<v-list-item
|
|
v-else
|
|
:link="!!subItem.path"
|
|
:prepend-icon="subItem.icon || mdiMenuRight"
|
|
:to="subItem.path"
|
|
@click="emitSelect(subItem)"
|
|
>
|
|
<template #title>
|
|
<v-tooltip location="end" :text="subItem.title" :aria-label="subItem.title">
|
|
<template #activator="{ props: tooltipProps }">
|
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
|
|
subItem.title
|
|
}}</span>
|
|
</template>
|
|
</v-tooltip>
|
|
</template>
|
|
</v-list-item>
|
|
</template>
|
|
</v-list-group>
|
|
|
|
<v-list-item
|
|
v-else
|
|
:class="{ 'px-0': isShrink }"
|
|
:link="!!item.path"
|
|
:to="item.path"
|
|
@click="emitSelect(item)"
|
|
>
|
|
<template #prepend>
|
|
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
|
|
<v-btn
|
|
v-if="isShrink && !item.icon"
|
|
class=""
|
|
rounded
|
|
size="36"
|
|
spaced="start"
|
|
variant="text"
|
|
>{{ item.title?.charAt(0) }}</v-btn
|
|
>
|
|
</template>
|
|
<template #title>
|
|
<v-tooltip v-if="!isShrink" location="end" :text="item.title" :aria-label="item.title">
|
|
<template #activator="{ props: tooltipProps }">
|
|
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
|
|
item.title
|
|
}}</span>
|
|
</template>
|
|
</v-tooltip>
|
|
</template>
|
|
</v-list-item>
|
|
</template>
|
|
</v-list>
|
|
</nav>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { AdminLayoutMenuItem } from './types'
|
|
import { mdiCircleSmall, mdiMenuRight } from '@mdi/js'
|
|
import { computed, watch } from 'vue'
|
|
|
|
interface Props {
|
|
opened?: string[]
|
|
menuItems?: AdminLayoutMenuItem[]
|
|
isShrink?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
opened: () => [],
|
|
menuItems: () => [],
|
|
isShrink: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:opened': [value: string[]]
|
|
select: [item: AdminLayoutMenuItem]
|
|
unshrink: []
|
|
}>()
|
|
|
|
const openedModel = computed({
|
|
get: () => (props.isShrink ? [] : props.opened),
|
|
set: (value) => {
|
|
if (!props.isShrink) {
|
|
emit('update:opened', value)
|
|
}
|
|
},
|
|
})
|
|
|
|
// 當側邊欄收縮時,自動收起所有展開的子選單
|
|
watch(
|
|
() => props.isShrink,
|
|
(newVal) => {
|
|
if (newVal) {
|
|
openedModel.value = []
|
|
}
|
|
}
|
|
)
|
|
|
|
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
|
|
|
|
function emitSelect(item: AdminLayoutMenuItem) {
|
|
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
|
|
// 這樣可以讓使用者看到完整的選單結構和導航結果
|
|
if (props.isShrink) {
|
|
emit('unshrink')
|
|
}
|
|
emit('select', item)
|
|
}
|
|
|
|
function getItemCount(item: AdminLayoutMenuItem) {
|
|
if (!item?.subItems?.length) return 0
|
|
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
|
|
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
|
|
if (current?.subItems?.length) return total + countLeaf(current.subItems)
|
|
return total + 1
|
|
}, 0)
|
|
return countLeaf(item.subItems)
|
|
}
|
|
|
|
function getGroupValue(item: AdminLayoutMenuItem, parentKey?: string) {
|
|
const rawKey = item.path ?? item.title ?? 'group'
|
|
const normalizedKey = Array.from(rawKey.trim())
|
|
.map((char) => {
|
|
if (/[a-z0-9]/i.test(char)) {
|
|
return char.toLowerCase()
|
|
}
|
|
|
|
return `u${char.codePointAt(0)?.toString(16)}`
|
|
})
|
|
.join('-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
|
|
if (!parentKey) {
|
|
return `menu-${normalizedKey || 'group'}`
|
|
}
|
|
|
|
return `${parentKey}__${normalizedKey || 'group'}`
|
|
}
|
|
|
|
function getGroupId(item: AdminLayoutMenuItem, parentId?: string) {
|
|
const groupId = getGroupValue(item).replace(/^menu-/, 'group-')
|
|
|
|
if (!parentId) {
|
|
return `nav-${groupId}`
|
|
}
|
|
|
|
return `${parentId}__${groupId}`
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.menu-count {
|
|
min-width: 28px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.nav-text-overflow {
|
|
display: block;
|
|
max-width: 100%;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
</style>
|