296 lines
7.8 KiB
Plaintext
296 lines
7.8 KiB
Plaintext
<template>
|
||
<view
|
||
class="admin-subsider"
|
||
:class="{ 'is-hidden': !visible, 'sub-sider-overlay': isOverlay || layoutMode === 'mobile' }"
|
||
:style="{ left: asideWidth + 'px', width: width + 'px' }"
|
||
>
|
||
<!-- 头部模块标题 (1:1 复刻 CRMEB) -->
|
||
<view v-if="menuTitle" class="subsider-cat-name">
|
||
<text class="cat-title">{{ menuTitle }}</text>
|
||
</view>
|
||
|
||
<!-- 菜单列表滚动 -->
|
||
<scroll-view class="subsider-content" scroll-y="true" show-scrollbar="false">
|
||
<view class="menu-list">
|
||
<!-- 分级手动多层渲染 (UTS 下直接循环比递归更稳定) -->
|
||
<template v-for="level1 in menuTree" :key="level1.id">
|
||
<!-- 一级层级 (通常是分组或顶级页面) -->
|
||
<view
|
||
class="menu-row level-1"
|
||
:class="{ 'is-active': isActive(level1), 'is-open': isOpen(level1.id, 1) }"
|
||
@click="handleNodeClick(level1, 1)"
|
||
>
|
||
<text class="menu-text">{{ level1.title }}</text>
|
||
<text v-if="level1.type === 'group'" class="chevron">▶</text>
|
||
</view>
|
||
|
||
<!-- 二级子菜单容器 -->
|
||
<transition name="expand">
|
||
<view v-if="level1.type === 'group' && isOpen(level1.id, 1)" class="sub-menu-container">
|
||
<template v-for="level2 in level1.children" :key="level2.id">
|
||
<view
|
||
class="menu-row level-2"
|
||
:class="{ 'is-active': isActive(level2), 'is-open': isOpen(level2.id, 2) }"
|
||
@click="handleNodeClick(level2, 2, level1.id)"
|
||
>
|
||
<text class="menu-text">{{ level2.title }}</text>
|
||
<text v-if="level2.type === 'group'" class="chevron">▶</text>
|
||
</view>
|
||
|
||
<!-- 三级子菜单容器 -->
|
||
<transition name="expand">
|
||
<view v-if="level2.type === 'group' && isOpen(level2.id, 2)" class="sub-menu-container">
|
||
<template v-for="level3 in level2.children" :key="level3.id">
|
||
<view
|
||
class="menu-row level-3"
|
||
:class="{ 'is-active': isActive(level3) }"
|
||
@click="handleNodeClick(level3, 3, level2.id)"
|
||
>
|
||
<text class="menu-text">{{ level3.title }}</text>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</transition>
|
||
</template>
|
||
</view>
|
||
</transition>
|
||
</template>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { ref, reactive, watch, onMounted } from 'vue'
|
||
import type { MenuNode } from '../router/settingSubSiderMenu.uts'
|
||
|
||
const props = defineProps<{
|
||
visible: boolean
|
||
menuTitle: string
|
||
menuTree: MenuNode[]
|
||
activeId: string
|
||
currentPath: string
|
||
asideWidth: number
|
||
width: number
|
||
isOverlay?: boolean
|
||
layoutMode?: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'sub-click', payload: { id: string, path: string }): void
|
||
}>()
|
||
|
||
// --- 展开状态管理 ---
|
||
const openIdsByLevel = reactive<Record<number, string>>({
|
||
1: '',
|
||
2: '',
|
||
3: ''
|
||
})
|
||
|
||
/** 判断项是否激活 (页面级) */
|
||
function isActive(node: MenuNode): boolean {
|
||
// 只有非分组项才能被视为“激活”变色 (1:1 复刻 screenshot 效果)
|
||
if (node.type === 'group') return false
|
||
|
||
// 核心逻辑:优先匹配 ID,ID 是唯一的
|
||
// 如果当前选中的 ID 就是这个节点的 ID,那它肯定是激活的
|
||
if (props.activeId != '' && node.id === props.activeId) {
|
||
return true
|
||
}
|
||
|
||
// 如果 ID 没匹配上,但路径匹配上了,且当前没有 activeId (例如首次进入或刷新)
|
||
// 或者当前 ID 对应的路径跟这个路径一样
|
||
// 但为了防止 Path 相同导致多选,我们加一个额外判断:
|
||
// 如果 activeId 已经指向了一个有效的路由,我们通常不再通过 Path 来高亮其他项
|
||
if (props.activeId === '' && props.currentPath != '' && node.path === props.currentPath) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/** 判断分组是否展开 */
|
||
function isOpen(id: string, level: number): boolean {
|
||
return openIdsByLevel[level] === id
|
||
}
|
||
|
||
/** 统一点击处理 */
|
||
function handleNodeClick(node: MenuNode, level: number, parentId: string = ''): void {
|
||
if (node.type === 'group') {
|
||
// 手风琴逻辑
|
||
if (openIdsByLevel[level] === node.id) {
|
||
openIdsByLevel[level] = ''
|
||
} else {
|
||
openIdsByLevel[level] = node.id
|
||
}
|
||
} else {
|
||
// 页面跳转
|
||
emit('sub-click', { id: node.id, path: node.path || '' })
|
||
}
|
||
}
|
||
|
||
/** 自动展开逻辑:根据当前激活项回溯父级展开状态 */
|
||
function autoExpand() {
|
||
const targetId = props.activeId || ''
|
||
const targetPath = props.currentPath || ''
|
||
|
||
if (targetId === '' && targetPath === '') return
|
||
|
||
const findPath = (nodes: MenuNode[]): string[] | null => {
|
||
for (const node of nodes) {
|
||
if (node.type === 'page' && ((targetId != '' && node.id === targetId) || (targetPath != '' && node.path === targetPath))) {
|
||
return [node.id]
|
||
}
|
||
if (node.children != null) {
|
||
const childPath = findPath(node.children!)
|
||
if (childPath != null) {
|
||
return [node.id, ...childPath]
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const path = findPath(props.menuTree)
|
||
if (path != null) {
|
||
// path 包含了从祖先到叶子的所有 ID
|
||
for (let i = 0; i < path.length - 1; i++) {
|
||
openIdsByLevel[i + 1] = path[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
watch(() => props.activeId, () => autoExpand())
|
||
watch(() => props.currentPath, () => autoExpand())
|
||
watch(() => props.menuTree, () => autoExpand(), { immediate: true })
|
||
|
||
onMounted(() => {
|
||
autoExpand()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.admin-subsider {
|
||
position: fixed;
|
||
top: 0;
|
||
bottom: 0;
|
||
background: #ffffff;
|
||
border-right: 1px solid #f0f0f0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 1000;
|
||
transition: transform 300ms ease, opacity 250ms ease;
|
||
}
|
||
|
||
.admin-subsider.is-hidden {
|
||
transform: translate3d(-100%, 0, 0);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.sub-sider-overlay {
|
||
z-index: 1010;
|
||
box-shadow: 6px 0 16px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.subsider-cat-name {
|
||
height: 50px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
background-color: #fff;
|
||
flex-shrink: 0;
|
||
|
||
.cat-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
}
|
||
|
||
.subsider-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.menu-list {
|
||
padding: 0; /* 移除 8px 边距,确保菜单项紧贴顶部/标题栏 */
|
||
}
|
||
|
||
.menu-row {
|
||
height: 50px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
position: relative;
|
||
width: 100%;
|
||
border-bottom: 1px solid transparent; /* 保持布局稳定 */
|
||
|
||
&.is-active {
|
||
background-color: #e7f0ff;
|
||
.menu-text {
|
||
color: #2f65ff;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.menu-text {
|
||
font-size: 14px;
|
||
color: #333;
|
||
flex: 1;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* 1:1 复刻 CRMEB 缩进逻辑 */
|
||
&.level-1 {
|
||
padding-left: 20px;
|
||
padding-right: 16px;
|
||
}
|
||
|
||
&.level-2 {
|
||
padding-left: 44px; /* 二级缩进 */
|
||
padding-right: 16px;
|
||
}
|
||
|
||
&.level-3 {
|
||
padding-left: 64px; /* 三级进一步缩进 */
|
||
padding-right: 16px;
|
||
}
|
||
}
|
||
|
||
.chevron {
|
||
font-size: 10px;
|
||
color: #c0c4cc;
|
||
margin-left: 4px;
|
||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
/* 旋转动画 */
|
||
.is-open .chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.sub-menu-container {
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 展开收起动画 */
|
||
.expand-enter-active,
|
||
.expand-leave-active {
|
||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||
max-height: 1000px; /* 足够大的预设值 */
|
||
}
|
||
|
||
.expand-enter-from,
|
||
.expand-leave-to {
|
||
max-height: 0;
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
</style>
|