227 lines
5.9 KiB
Vue
227 lines
5.9 KiB
Vue
<template>
|
||
<div class="app-sidebar">
|
||
<!-- Logo区域 -->
|
||
<div class="sidebar-header">
|
||
<div class="logo">
|
||
<router-link to="/" class="logo-link">
|
||
<img src="/logo.svg" :alt="appName" width="32" height="32" />
|
||
<span v-if="!collapsed" class="logo-text">{{ appName }}</span>
|
||
</router-link>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 导航菜单 -->
|
||
<div class="menu-container">
|
||
<n-menu
|
||
:collapsed="collapsed"
|
||
:collapsed-width="64"
|
||
:collapsed-icon-size="24"
|
||
:options="menuOptions"
|
||
:value="currentRoute"
|
||
@update:value="handleMenuSelect"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 用户菜单区域 -->
|
||
<div class="sidebar-footer">
|
||
<UserMenu :collapsed="collapsed" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, h } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { NMenu } from 'naive-ui'
|
||
import { t } from '../../shared/i18n'
|
||
import { useMenuStore, type AppMenuItem } from '../../shared/stores/menu'
|
||
import { pageTypeToSlug } from '../../shared/utils/slug'
|
||
import DynamicIcon from '../../core/components/DynamicIcon.vue'
|
||
import UserMenu from '../../shared/components/UserMenu.vue'
|
||
|
||
interface Props {
|
||
collapsed: boolean
|
||
}
|
||
|
||
defineProps<Props>()
|
||
|
||
// 定义emit事件
|
||
const emit = defineEmits<{
|
||
'menu-select': []
|
||
}>()
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const currentRoute = computed(() => route.name as string)
|
||
|
||
const menuStore = useMenuStore()
|
||
|
||
// 从 localStorage 读取应用名称
|
||
const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
|
||
|
||
// 根据 parentId 构建树,并在 group 类型上承载二级菜单
|
||
type MenuOption = {
|
||
label: string
|
||
key: string
|
||
icon?: any
|
||
children?: MenuOption[]
|
||
}
|
||
|
||
const buildTree = (items: AppMenuItem[]) => {
|
||
const byId: Record<string, AppMenuItem & { children: (AppMenuItem & { children?: any[] })[] }> = {}
|
||
items.forEach((i: AppMenuItem) => (byId[i.id] = { ...(i as any), children: [] }))
|
||
const roots: (AppMenuItem & { children: any[] })[] = []
|
||
items.forEach((i: AppMenuItem) => {
|
||
const pid = (i as any).parentId as string | null | undefined
|
||
if (pid && byId[pid]) byId[pid].children.push(byId[i.id])
|
||
else roots.push(byId[i.id])
|
||
})
|
||
return roots
|
||
}
|
||
|
||
// 默认不自动展开任何二级菜单
|
||
|
||
const renderIcon = (name?: string, size = 24) =>
|
||
name
|
||
? () => h(DynamicIcon, { name, size, color: '#64748b', iconLibrary: 'tabler' })
|
||
: undefined
|
||
|
||
const toMenuOptions = (nodes: (AppMenuItem & { children?: any[] })[]): MenuOption[] =>
|
||
nodes.map((n: any) => ({
|
||
label: t(n.label),
|
||
key: n.id,
|
||
icon: renderIcon(n.icon, 24),
|
||
children: n.children && n.children.length
|
||
? n.children.map((c: any) => ({
|
||
label: t(c.label),
|
||
key: c.id,
|
||
icon: renderIcon(c.icon, 20),
|
||
children: c.children?.length ? toMenuOptions(c.children as any) : undefined
|
||
}))
|
||
: undefined
|
||
}))
|
||
|
||
const menuOptions = computed(() => {
|
||
const tree = buildTree(menuStore.visibleItems)
|
||
return toMenuOptions(tree)
|
||
})
|
||
|
||
const handleMenuSelect = (key: string) => {
|
||
// 支持点击任意层级项:在全量 items 中查找
|
||
const menuItem = menuStore.items.find(item => item.id === key)
|
||
if (!menuItem) return
|
||
|
||
// 发出菜单选择事件,让父组件处理移动端关闭逻辑
|
||
emit('menu-select')
|
||
|
||
// 根据菜单类型处理导航
|
||
if (menuItem.type === 'pagetype' && menuItem.pagetype) {
|
||
// 页面类型:统一跳转到列表页,由列表页内部判断是否为单页模式
|
||
const slug = pageTypeToSlug(menuItem.pagetype)
|
||
router.push({ name: 'PageTypeList', params: { entity: slug } })
|
||
} else if (menuItem.type === 'route' && menuItem.routeName) {
|
||
// 路由名:使用routeName字段
|
||
router.push({ name: menuItem.routeName })
|
||
} else if (menuItem.type === 'url' && menuItem.url) {
|
||
// URL:直接使用url字段
|
||
if (menuItem.url.startsWith('http://') || menuItem.url.startsWith('https://')) {
|
||
// 外部链接,在新窗口打开
|
||
window.open(menuItem.url, '_blank')
|
||
} else {
|
||
// 内部链接,使用router跳转
|
||
router.push(menuItem.url)
|
||
}
|
||
} else {
|
||
// 新增:Workspace 类型
|
||
if (menuItem.type === 'workspace' && menuItem.workspaceName) {
|
||
const slug = pageTypeToSlug(menuItem.workspaceName)
|
||
router.push(`/workspace/${slug}`)
|
||
return
|
||
}
|
||
// 兼容旧版本:优先使用url字段,如果没有则使用routeName
|
||
if (menuItem.url) {
|
||
if (menuItem.url.startsWith('http://') || menuItem.url.startsWith('https://')) {
|
||
window.open(menuItem.url, '_blank')
|
||
} else {
|
||
router.push(menuItem.url)
|
||
}
|
||
} else if (menuItem.routeName) {
|
||
router.push({ name: menuItem.routeName })
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.app-sidebar {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 16px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.menu-container {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.logo-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
color: inherit;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.logo-text {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 菜单图标样式优化 */
|
||
:deep(.n-menu-item-content) {
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
:deep(.n-menu-item-content:hover) {
|
||
background-color: rgba(24, 160, 88, 0.1);
|
||
}
|
||
|
||
:deep(.n-menu-item-content.n-menu-item-content--selected) {
|
||
background-color: rgba(24, 160, 88, 0.15);
|
||
}
|
||
|
||
:deep(.n-menu-item-content.n-menu-item-content--selected .n-icon) {
|
||
color: #18a058 !important;
|
||
}
|
||
|
||
:deep(.n-menu-item-content:hover .n-icon) {
|
||
color: #18a058 !important;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* 侧边栏底部用户菜单区域 */
|
||
.sidebar-footer {
|
||
flex-shrink: 0;
|
||
padding: 12px;
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
</style>
|