350 lines
8.9 KiB
Plaintext
350 lines
8.9 KiB
Plaintext
<template>
|
||
<view
|
||
class="sub-sider"
|
||
v-if="groups && groups.length > 0"
|
||
:style="{ left: props.asideWidth + 'px', width: props.siderWidth + 'px' }"
|
||
>
|
||
<view class="sub-header">
|
||
<text class="sub-title">{{ activeMenuTitle }}</text>
|
||
</view>
|
||
|
||
<scroll-view class="sub-body" scroll-y="true">
|
||
<view
|
||
v-for="g in groups"
|
||
:key="groupKey(g)"
|
||
class="group"
|
||
>
|
||
<view class="group-title" @click="toggleGroup(groupKey(g))">
|
||
<text class="group-title-text">{{ g.title }}</text>
|
||
<text class="group-arrow" :class="{ open: isGroupOpen(groupKey(g)) }">v</text>
|
||
</view>
|
||
|
||
<view v-show="isGroupOpen(groupKey(g))" class="group-children">
|
||
<view
|
||
v-for="c in g.children"
|
||
:key="c.id"
|
||
class="sub-item"
|
||
:class="{ active: resolvedActiveId === c.id }"
|
||
@click.stop="handleClick(c)"
|
||
>
|
||
<text class="sub-item-text" :class="{ activeText: resolvedActiveId === c.id }">
|
||
{{ c.title }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { ref, computed, watch } from 'vue'
|
||
import type { MenuGroup, MenuChild } from '../types.uts'
|
||
|
||
const props = defineProps<{
|
||
activeMenuTitle: string
|
||
groups: MenuGroup[]
|
||
activeSubId: string
|
||
asideWidth: number
|
||
siderWidth: number
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'sub-click', child: MenuChild): void
|
||
}>()
|
||
|
||
/** 只展开一个分组(更像 CRMEB) */
|
||
const openGroupKey = ref<string>('')
|
||
|
||
/** 给 group 一个稳定 key:优先用 id(如果你 types 里没有 id,就用 title) */
|
||
const groupKey = (g: MenuGroup): string => {
|
||
// @ts-ignore - 允许 g.id 可选
|
||
const anyG: any = g as any
|
||
return (anyG.id && (anyG.id as string)) ? (anyG.id as string) : g.title
|
||
}
|
||
|
||
const normalizePath = (p: string): string => {
|
||
if (!p) return ''
|
||
// 统一去掉开头 /
|
||
const s = p.startsWith('/') ? p.slice(1) : p
|
||
// 去掉 query
|
||
const q = s.indexOf('?')
|
||
return q >= 0 ? s.slice(0, q) : s
|
||
}
|
||
|
||
/** 扁平查找:id -> child */
|
||
const findChildById = (id: string): MenuChild | null => {
|
||
if (!id) return null
|
||
for (const g of props.groups) {
|
||
for (const c of g.children) {
|
||
if (c.id === id) return c
|
||
// 兼容未来有更深层 children(递归)
|
||
// @ts-ignore
|
||
const anyC: any = c as any
|
||
// @ts-ignore
|
||
if (anyC.children && (anyC.children as MenuChild[]).length > 0) {
|
||
// @ts-ignore
|
||
const hit = findFirstMatchInChildren(id, anyC.children as MenuChild[])
|
||
if (hit) return hit
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// @ts-ignore
|
||
const findFirstMatchInChildren = (id: string, list: MenuChild[]): MenuChild | null => {
|
||
for (const n of list) {
|
||
if (n.id === id) return n
|
||
// @ts-ignore
|
||
const anyN: any = n as any
|
||
// @ts-ignore
|
||
if (anyN.children && (anyN.children as MenuChild[]).length > 0) {
|
||
// @ts-ignore
|
||
const hit = findFirstMatchInChildren(id, anyN.children as MenuChild[])
|
||
if (hit) return hit
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const findChildByPath = (path: string): MenuChild | null => {
|
||
const target = normalizePath(path)
|
||
if (!target) return null
|
||
for (const g of props.groups) {
|
||
for (const c of g.children) {
|
||
if (normalizePath(c.path) === target) return c
|
||
// 兼容更深层 children(递归)
|
||
// @ts-ignore
|
||
const anyC: any = c as any
|
||
// @ts-ignore
|
||
if (anyC.children && (anyC.children as MenuChild[]).length > 0) {
|
||
// @ts-ignore
|
||
const hit = findFirstMatchByPath(target, anyC.children as MenuChild[])
|
||
if (hit) return hit
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// @ts-ignore
|
||
const findFirstMatchByPath = (targetNorm: string, list: MenuChild[]): MenuChild | null => {
|
||
for (const n of list) {
|
||
if (normalizePath(n.path) === targetNorm) return n
|
||
// @ts-ignore
|
||
const anyN: any = n as any
|
||
// @ts-ignore
|
||
if (anyN.children && (anyN.children as MenuChild[]).length > 0) {
|
||
// @ts-ignore
|
||
const hit = findFirstMatchByPath(targetNorm, anyN.children as MenuChild[])
|
||
if (hit) return hit
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 找到某个 child 属于哪个 group,用于自动展开该组 */
|
||
const findGroupKeyByChildId = (id: string): string => {
|
||
for (const g of props.groups) {
|
||
for (const c of g.children) {
|
||
if (c.id === id) return groupKey(g)
|
||
// @ts-ignore
|
||
const anyC: any = c as any
|
||
// @ts-ignore
|
||
if (anyC.children && (anyC.children as MenuChild[]).length > 0) {
|
||
// 深层命中也算这个 group
|
||
// @ts-ignore
|
||
const hit = findFirstMatchInChildren(id, anyC.children as MenuChild[])
|
||
if (hit) return groupKey(g)
|
||
}
|
||
}
|
||
}
|
||
// fallback:第一个 group
|
||
return props.groups.length > 0 ? groupKey(props.groups[0]) : ''
|
||
}
|
||
|
||
/** 递归取第一个 leaf 菜单:你要求的“默认打开第一个页面(递归)” */
|
||
const firstLeaf = (): MenuChild | null => {
|
||
if (!props.groups || props.groups.length === 0) return null
|
||
const g0 = props.groups[0]
|
||
if (!g0.children || g0.children.length === 0) return null
|
||
|
||
const c0: any = g0.children[0] as any
|
||
if (c0.children && (c0.children as MenuChild[]).length > 0) {
|
||
// @ts-ignore
|
||
return findFirstLeafInChildren(c0.children as MenuChild[])
|
||
}
|
||
return g0.children[0]
|
||
}
|
||
|
||
// @ts-ignore
|
||
const findFirstLeafInChildren = (list: MenuChild[]): MenuChild | null => {
|
||
if (!list || list.length === 0) return null
|
||
const n: any = list[0] as any
|
||
if (n.children && (n.children as MenuChild[]).length > 0) {
|
||
// @ts-ignore
|
||
return findFirstLeafInChildren(n.children as MenuChild[])
|
||
}
|
||
return list[0]
|
||
}
|
||
|
||
/**
|
||
* 高亮兜底:如果 activeSubId 没同步(你现在遇到的“点了不高亮”),
|
||
* 就按当前页面 route/path 再匹配一次。
|
||
* uni-app-x 的 getCurrentPages() 返回页面对象,page.route 可取当前路由。:contentReference[oaicite:3]{index=3}
|
||
*/
|
||
const resolvedActiveId = computed((): string => {
|
||
const byId = findChildById(props.activeSubId)
|
||
if (byId) return byId.id
|
||
|
||
try {
|
||
const pages = getCurrentPages()
|
||
if (pages && pages.length > 0) {
|
||
// @ts-ignore
|
||
const cur: any = pages[pages.length - 1]
|
||
// @ts-ignore
|
||
const route: string = cur.route ? (cur.route as string) : ''
|
||
const hit = findChildByPath(route)
|
||
if (hit) return hit.id
|
||
}
|
||
} catch (e) {}
|
||
|
||
return props.activeSubId || ''
|
||
})
|
||
|
||
const isGroupOpen = (key: string): boolean => {
|
||
if (!openGroupKey.value) {
|
||
// 初始默认展开第一个组
|
||
return props.groups && props.groups.length > 0 ? groupKey(props.groups[0]) === key : false
|
||
}
|
||
return openGroupKey.value === key
|
||
}
|
||
|
||
const toggleGroup = (key: string) => {
|
||
openGroupKey.value = (openGroupKey.value === key) ? '' : key
|
||
}
|
||
|
||
const handleClick = (c: MenuChild) => {
|
||
// 点击就让该组展开(用户体验更 CRMEB)
|
||
openGroupKey.value = findGroupKeyByChildId(c.id)
|
||
emit('sub-click', c)
|
||
}
|
||
|
||
/** 自动:当 groups 变了/activeSubId 无效时,默认跳第一个 leaf(递归)并展开对应 group */
|
||
const ensureDefault = () => {
|
||
if (!props.groups || props.groups.length === 0) return
|
||
|
||
// activeSubId 有效:展开它所在组
|
||
const hit = findChildById(props.activeSubId)
|
||
if (hit) {
|
||
openGroupKey.value = findGroupKeyByChildId(hit.id)
|
||
return
|
||
}
|
||
|
||
// activeSubId 无效/空:默认递归选第一个 leaf
|
||
const first = firstLeaf()
|
||
if (first) {
|
||
openGroupKey.value = findGroupKeyByChildId(first.id)
|
||
emit('sub-click', first)
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.groups,
|
||
() => { ensureDefault() },
|
||
{ immediate: true, deep: true }
|
||
)
|
||
|
||
watch(
|
||
() => props.activeSubId,
|
||
() => { ensureDefault() },
|
||
{ immediate: true }
|
||
)
|
||
</script>
|
||
|
||
<style>
|
||
.sub-sider{
|
||
background:#ffffff;
|
||
border-right: 1px solid #e5e7eb;
|
||
height: 100vh;
|
||
position: fixed;
|
||
top: 0;
|
||
bottom: 0;
|
||
z-index: 900;
|
||
}
|
||
|
||
.sub-header{
|
||
height: 56px;
|
||
display:flex;
|
||
align-items:center;
|
||
padding: 0 16px;
|
||
border-bottom: 1px solid #eef2f7;
|
||
}
|
||
.sub-title{
|
||
font-size:16px;
|
||
font-weight:700;
|
||
color:#111827;
|
||
}
|
||
|
||
.sub-body{
|
||
height: calc(100vh - 56px);
|
||
}
|
||
|
||
/* CRMEB 风格:分组“块”更明显 */
|
||
.group{ padding: 8px 0; }
|
||
|
||
.group-title{
|
||
height: 42px;
|
||
margin: 8px 12px 6px 12px;
|
||
padding: 0 14px;
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content: space-between;
|
||
background: #f6f7fb;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.group-title-text{
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color:#111827;
|
||
}
|
||
|
||
.group-arrow{
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
transform: rotate(0deg);
|
||
}
|
||
.group-arrow.open{
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.group-children{
|
||
padding: 0 12px 6px 12px;
|
||
}
|
||
|
||
/* 子菜单:缩进 + 更小高度 */
|
||
.sub-item{
|
||
height: 34px;
|
||
margin: 4px 0;
|
||
padding: 0 14px 0 28px; /* 体现层级 */
|
||
display:flex;
|
||
align-items:center;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.sub-item.active{
|
||
background: #eaf2ff;
|
||
}
|
||
|
||
.sub-item-text{
|
||
font-size: 13px;
|
||
color:#374151;
|
||
}
|
||
.sub-item-text.activeText{
|
||
color:#1677ff;
|
||
font-weight: 700;
|
||
}
|
||
</style>
|