Files
medical-mall/layouts/admin/components/AdminSubsider.uvue
2026-02-11 20:16:51 +08:00

296 lines
7.8 KiB
Plaintext
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>
<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
// 核心逻辑:优先匹配 IDID 是唯一的
// 如果当前选中的 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>