Files
medical-mall/layouts/admin/components/AdminSubsider.uvue
2026-01-28 17:54:30 +08:00

350 lines
8.9 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="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>