Files
urbanLifeline/urbanLifelineWeb/packages/shared/src/layouts/SubSidebarLayout/SubSidebarLayout.vue
2025-12-20 12:55:43 +08:00

502 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="sidebar-layout" :class="{ 'no-sidebar': shouldHideSidebar }">
<!-- 侧边栏当只有一个菜单时隐藏 -->
<aside v-if="!shouldHideSidebar" class="sidebar" :class="{ collapsed: collapsed }">
<div class="sidebar-header">
<!-- Logo 区域插槽支持各服务自定义 -->
<slot name="logo" :collapsed="collapsed" :service="currentService">
<!-- 默认 Logo -->
<div class="logo">
<img :src="logoUrl" alt="Logo" class="logo-img" />
<span v-if="!collapsed" class="logo-text">{{ serviceTitle }}</span>
</div>
</slot>
<button
class="collapse-btn"
@click="toggleSidebar"
:title="collapsed ? '展开侧边栏' : '收起侧边栏'"
>
<PanelLeftClose v-if="!collapsed"/>
<PanelLeftOpen v-else/>
</button>
</div>
<nav class="nav-menu">
<div class="nav-section">
<!-- 前端分组 admin 路由 -->
<div v-if="frontendMenuItems.length > 0" class="nav-group">
<div
class="nav-item nav-group-item"
:class="{ 'has-children': true }"
@click="toggleGroup('frontend')"
>
<Monitor :size="16" />
<span v-if="!collapsed">前端</span>
<ChevronDown v-if="!collapsed && isGroupExpanded('frontend')" :size="14" class="expand-icon" />
<ChevronRight v-if="!collapsed && !isGroupExpanded('frontend')" :size="14" class="expand-icon" />
<div v-if="collapsed" class="nav-tooltip">
前端
<div class="tooltip-arrow"></div>
</div>
</div>
<!-- 前端子菜单 -->
<div v-if="isGroupExpanded('frontend') && !collapsed" class="nav-children">
<div
v-for="item in frontendMenuItems"
:key="item.key"
class="nav-child-wrapper"
>
<div
class="nav-item nav-child-item"
:class="{ active: isMenuActive(item), 'has-children': item.children }"
@click="item.children ? toggleMenu(item) : handleMenuClick(item)"
>
<component :is="item.icon" :size="16" />
<span>{{ item.label }}</span>
<ChevronDown v-if="item.children && item.expanded" :size="14" class="expand-icon" />
<ChevronRight v-if="item.children && !item.expanded" :size="14" class="expand-icon" />
</div>
<!-- 三级菜单 -->
<div v-if="item.children && item.expanded" class="nav-sub-children">
<div
v-for="child in item.children"
:key="child.key"
class="nav-item nav-sub-child-item"
:class="{ active: isMenuActive(child) }"
@click="handleMenuClick(child)"
>
<span>{{ child.label }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 后端分组admin 路由 -->
<div v-if="backendMenuItems.length > 0" class="nav-group">
<div
class="nav-item nav-group-item"
:class="{ 'has-children': true }"
@click="toggleGroup('backend')"
>
<Server :size="16" />
<span v-if="!collapsed">后端</span>
<ChevronDown v-if="!collapsed && isGroupExpanded('backend')" :size="14" class="expand-icon" />
<ChevronRight v-if="!collapsed && !isGroupExpanded('backend')" :size="14" class="expand-icon" />
<div v-if="collapsed" class="nav-tooltip">
后端
<div class="tooltip-arrow"></div>
</div>
</div>
<!-- 后端子菜单 -->
<div v-if="isGroupExpanded('backend') && !collapsed" class="nav-children">
<div
v-for="item in backendMenuItems"
:key="item.key"
class="nav-child-wrapper"
>
<div
class="nav-item nav-child-item"
:class="{ active: isMenuActive(item), 'has-children': item.children }"
@click="item.children ? toggleMenu(item) : handleMenuClick(item)"
>
<component :is="item.icon" :size="16" />
<span>{{ item.label }}</span>
<ChevronDown v-if="item.children && item.expanded" :size="14" class="expand-icon" />
<ChevronRight v-if="item.children && !item.expanded" :size="14" class="expand-icon" />
</div>
<!-- 三级菜单 -->
<div v-if="item.children && item.expanded" class="nav-sub-children">
<div
v-for="child in item.children"
:key="child.key"
class="nav-item nav-sub-child-item"
:class="{ active: isMenuActive(child) }"
@click="handleMenuClick(child)"
>
<span>{{ child.label }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
</aside>
<!-- 折叠时的展开按钮 -->
<button
v-if="collapsed && !shouldHideSidebar"
class="expand-toggle"
@click="toggleSidebar"
title="展开侧边栏"
>
<ChevronRight :size="18" />
</button>
<!-- 主内容区 -->
<main class="main-content">
<!-- iframe 模式 -->
<IframeView
v-if="currentIframeUrl"
:url="currentIframeUrl"
:title="currentMenuItem?.label"
:show-header="true"
/>
<!-- 路由模式 -->
<router-view v-else />
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
PanelLeftClose,
PanelLeftOpen,
Server,
Monitor,
ChevronDown,
ChevronRight
} from 'lucide-vue-next'
import { IframeView } from '@/components'
// ... (rest of the code remains the same)
import type { MenuItem } from '@/types/menu'
// 分组展开状态
const expandedGroups = ref<Set<string>>(new Set(['frontend', 'backend']))
interface Props {
service?: string // 服务名称platform, bidding, workcase
}
// ...
const props = withDefaults(defineProps<Props>(), {
service: undefined // 不设默认值,从路由自动检测
})
const router = useRouter()
const route = useRoute()
// 自动检测当前服务
const currentService = computed(() => {
// 优先使用 props
if (props.service) {
return props.service
}
// 从 route.meta 获取
const meta = route.meta as any
if (meta?.service) {
return meta.service
}
// 从 URL 路径推断服务(最可靠的方式)
const hostname = window.location.hostname
const pathname = window.location.pathname
// 根据 URL 路径判断服务
// localhost/workcase/... -> workcase
// localhost/platform/... -> platform
// localhost/bidding/... -> bidding
if (pathname.includes('/workcase/')) {
return 'workcase'
}
if (pathname.includes('/platform/')) {
return 'platform'
}
if (pathname.includes('/bidding/')) {
return 'bidding'
}
// 从 localStorage 的 loginDomain 中推断(基于当前路由匹配的视图)
try {
const loginDomainStr = localStorage.getItem('loginDomain')
if (loginDomainStr) {
const loginDomain = JSON.parse(loginDomainStr)
const userViews = loginDomain.userViews || []
// 找到当前路由对应的视图
const currentView = userViews.find((v: any) =>
v.layout === 'SubSidebarLayout' &&
v.url === route.path
)
if (currentView?.service) {
return currentView.service
}
// 如果没找到精确匹配,尝试前缀匹配
const matchedView = userViews.find((v: any) =>
v.layout === 'SubSidebarLayout' &&
route.path.startsWith(v.url)
)
if (matchedView?.service) {
return matchedView.service
}
}
} catch (error) {
console.error('自动检测服务失败:', error)
}
// 默认返回 workcase
return 'workcase'
})
// 动态 Logo URL
const logoUrl = computed(() => {
const service = currentService.value
// 根据不同服务返回对应的 logo 路径
const serviceLogos: Record<string, string> = {
'platform': '/platform/logo.jpg',
'workcase': '/workcase/logo.jpg',
'bidding': '/bidding/logo.jpg'
}
return serviceLogos[service] || '/logo.jpg' // 默认回退到根路径
})
// 服务标题
const serviceTitle = computed(() => {
const serviceTitles: Record<string, string> = {
'platform': '管理平台',
'workcase': '智能客服',
'bidding': '竞价平台'
}
return serviceTitles[currentService.value] || '城市生命线'
})
// 状态管理
const collapsed = ref(false)
const activeMenu = ref('home')
// 从 LocalStorage 获取用户名
function getUserName(): string {
try {
const loginDomainStr = localStorage.getItem('loginDomain')
if (loginDomainStr) {
const loginDomain = JSON.parse(loginDomainStr)
return loginDomain.user?.username || loginDomain.userInfo?.username || '管理员'
}
} catch (error) {
console.error('❌ 获取用户名失败:', error)
}
return '管理员'
}
const userName = ref(getUserName())
/**
* 从 LocalStorage 加载菜单
*/
function loadMenuFromStorage(): MenuItem[] {
try {
const loginDomainStr = localStorage.getItem('loginDomain')
if (!loginDomainStr) {
console.warn('⚠️ 未找到 loginDomain')
return []
}
const loginDomain = JSON.parse(loginDomainStr)
const userViews = loginDomain.userViews || []
const service = currentService.value
console.log(`📋 [${service}] 加载用户视图:`, userViews)
// 过滤出顶级菜单:需要 layout === 'SubSidebarLayout'
const topLevelViews = userViews.filter((view: any) =>
view.layout === 'SubSidebarLayout' &&
!view.parentId && // 顶级菜单没有 parentId
(view.type === 0 || view.type === 1) && // type 0=目录 1=菜单
view.service === service // 动态匹配服务
)
// 获取所有顶级菜单的 viewId
const topLevelIds = new Set(topLevelViews.map((v: any) => v.viewId))
// 过滤出子菜单parentId 指向顶级菜单(不要求子菜单有 layout
const childViews = userViews.filter((view: any) =>
view.parentId && // 有 parentId
topLevelIds.has(view.parentId) && // parentId 指向顶级菜单
(view.type === 0 || view.type === 1) &&
view.service === service
)
console.log(`🔍 [${service}] 顶级视图:`, topLevelViews)
console.log(`🔍 [${service}] 子视图:`, childViews)
// 按 orderNum 排序
topLevelViews.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0))
// 分离顶级菜单和子菜单
// 转换为 MenuItem 格式并构建树形结构
const menuItems: MenuItem[] = topLevelViews.map((view: any) => {
const isIframe = view.viewType === 'iframe' || !!view.iframeUrl
let menuUrl = view.url
if (isIframe && view.url && (view.url.startsWith('http://') || view.url.startsWith('https://'))) {
menuUrl = `/${view.viewId}`
}
// 查找子菜单
const children = childViews
.filter((child: any) => child.parentId === view.viewId)
.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0))
.map((child: any) => ({
key: child.viewId || child.name,
label: child.name,
icon: child.icon || 'Document',
url: child.url,
type: (child.viewType === 'iframe' || !!child.iframeUrl) ? 'iframe' : 'route' as 'iframe' | 'route'
}))
return {
key: view.viewId || view.name,
label: view.name,
icon: view.icon || 'Grid',
url: menuUrl,
type: isIframe ? 'iframe' : 'route',
children: children.length > 0 ? children : undefined,
expanded: false // 默认折叠
}
})
console.log('✅ 侧边栏菜单:', menuItems)
return menuItems
} catch (error) {
console.error('❌ 加载菜单失败:', error)
return []
}
}
// 菜单配置(从 LocalStorage 加载)
const menuItems = ref<MenuItem[]>(loadMenuFromStorage())
// 前端菜单(非 admin 路由)
const frontendMenuItems = computed(() => {
return menuItems.value.filter(item => !item.url?.includes('/admin'))
})
// 后端菜单admin 路由)
const backendMenuItems = computed(() => {
return menuItems.value.filter(item => item.url?.includes('/admin'))
})
// 计算总菜单数量(包括子菜单)
const totalMenuCount = computed(() => {
let count = 0
const countItems = (items: MenuItem[]) => {
items.forEach(item => {
count++
if (item.children) {
countItems(item.children)
}
})
}
countItems(menuItems.value)
return count
})
// 当只有一个菜单时隐藏 sidebar
const shouldHideSidebar = computed(() => {
return totalMenuCount.value <= 1
})
// 当前菜单项
const currentMenuItem = computed(() => {
// 在所有菜单中查找(包括子菜单)
const findInItems = (items: MenuItem[]): MenuItem | undefined => {
for (const item of items) {
if (item.key === activeMenu.value) return item
if (item.children) {
const found = findInItems(item.children)
if (found) return found
}
}
return undefined
}
return findInItems(menuItems.value)
})
// 当前 iframe URL从路由 meta 读取)
const currentIframeUrl = computed(() => {
const meta = route.meta as any
return meta?.iframeUrl || null
})
// 切换分组展开/折叠
const toggleGroup = (group: string) => {
if (expandedGroups.value.has(group)) {
expandedGroups.value.delete(group)
} else {
expandedGroups.value.add(group)
}
}
// 检查分组是否展开
const isGroupExpanded = (group: string) => {
return expandedGroups.value.has(group)
}
// 检查菜单是否激活
const isMenuActive = (item: MenuItem) => {
if (item.url === route.path) return true
if (item.key === activeMenu.value) return true
// 检查子菜单是否激活
if (item.children) {
return item.children.some(child => child.url === route.path || child.key === activeMenu.value)
}
return false
}
// 切换侧边栏
const toggleSidebar = () => {
collapsed.value = !collapsed.value
}
// 切换菜单展开/折叠
const toggleMenu = (item: MenuItem) => {
if (item.children) {
item.expanded = !item.expanded
}
}
// 处理菜单点击
const handleMenuClick = (item: MenuItem) => {
activeMenu.value = item.key || ''
// 所有菜单都通过路由跳转
if (item.url) {
router.push(item.url)
}
}
// 监听服务变化,重新加载菜单
watch(
currentService,
() => {
console.log(`🔄 服务切换到: ${currentService.value},重新加载菜单`)
menuItems.value = loadMenuFromStorage()
}
)
// 监听路由变化,同步激活菜单
watch(
() => route.path,
(newPath) => {
// 查找匹配的菜单项route 或 iframe 类型)
const menuItem = menuItems.value.find((item: MenuItem) => item.url === newPath)
if (menuItem) {
activeMenu.value = menuItem.key
} else {
// 如果路径不匹配,尝试通过 route.name 匹配 viewId
const menuByName = menuItems.value.find((item: MenuItem) => item.key === route.name)
if (menuByName) {
activeMenu.value = menuByName.key
}
}
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
@import url("./SubSidebarLayout.scss");
</style>