修改页面结构
This commit is contained in:
@@ -1,89 +1,193 @@
|
||||
<template>
|
||||
<view
|
||||
class="aside"
|
||||
:style="{ width: asideWidth + 'px' }"
|
||||
>
|
||||
<view class="aside-header">
|
||||
<view class="logo">
|
||||
<text class="logo-text"> 商城后台</text>
|
||||
</view>
|
||||
<view class="admin-aside" :class="{ collapsed: collapsed }" :style="{ width: asideWidth + 'px' }">
|
||||
<view class="aside-logo" @click="onLogoClick">
|
||||
<text class="logo-text">{{ collapsed ? 'M' : 'MALL' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="aside-menu">
|
||||
<view
|
||||
v-for="m in menuList"
|
||||
:key="m.id"
|
||||
class="aside-item"
|
||||
:class="{ active: activeMenuId === m.id }"
|
||||
@click="$emit('menu-click', m.id)"
|
||||
<scroll-view class="aside-menu" scroll-y="true">
|
||||
<view
|
||||
v-for="menu in topMenus"
|
||||
:key="menu.id"
|
||||
class="menu-item"
|
||||
:class="{ active: menu.id === activeTopMenuId }"
|
||||
@click="onMenuClick(menu)"
|
||||
>
|
||||
<image class="aside-icon" :src="m.icon" mode="aspectFit" />
|
||||
<text class="aside-title" v-if="!collapsed">{{ m.title }}</text>
|
||||
<view class="menu-icon">
|
||||
<text class="icon-text">{{ getIconText(menu.icon) }}</text>
|
||||
</view>
|
||||
<view v-if="!collapsed" class="menu-title">
|
||||
<text>{{ menu.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="aside-footer" @click="onToggle">
|
||||
<view class="toggle-btn">
|
||||
<text class="toggle-icon">{{ collapsed ? '>' : '<' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import type { MenuItem } from '../types.uts'
|
||||
import type { TopMenu } from '@/layouts/admin/router/adminRoutes.uts'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
collapsed: boolean
|
||||
menuList: MenuItem[]
|
||||
activeMenuId: string
|
||||
asideWidth:number
|
||||
topMenus: TopMenu[]
|
||||
activeTopMenuId: string
|
||||
asideWidth: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e:'menu-click', menuId: string): void
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
(e: 'menu-click', menu: TopMenu): void
|
||||
}>()
|
||||
|
||||
function getIconText(icon: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'home': 'H',
|
||||
'user': 'U',
|
||||
'product': 'P',
|
||||
'order': 'O',
|
||||
'marketing': 'M',
|
||||
'content': 'C',
|
||||
'finance': 'F',
|
||||
'statistic': 'S',
|
||||
'setting': 'T'
|
||||
}
|
||||
return iconMap[icon] || icon.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function onMenuClick(menu: TopMenu): void {
|
||||
emit('menu-click', menu)
|
||||
}
|
||||
|
||||
function onToggle(): void {
|
||||
emit('toggle')
|
||||
}
|
||||
|
||||
function onLogoClick(): void {
|
||||
const homeMenu = props.topMenus.find(m => m.id === 'home')
|
||||
if (homeMenu) {
|
||||
emit('menu-click', homeMenu)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.aside{
|
||||
background: #1f2a37;
|
||||
height: 100vh;
|
||||
<style scoped lang="scss">
|
||||
.admin-aside {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display:flex;
|
||||
background: #001529;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
z-index: 1000;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.aside-header{
|
||||
height: 56px;
|
||||
display:flex;
|
||||
flex-direction:row;
|
||||
align-items:center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.logo-text{
|
||||
color:#fff;
|
||||
font-size:20px;
|
||||
font-weight:600;
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
text-align:center;
|
||||
}
|
||||
.collapse-btn{ width:28px; height:28px; display:flex; align-items:center; justify-content:center; }
|
||||
.collapse-text{ color:rgba(255,255,255,0.7); }
|
||||
|
||||
.aside-menu{ padding: 8px 0; }
|
||||
.aside-item{
|
||||
height: 54px;
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap: 6px;
|
||||
margin: 6px 10px;
|
||||
border-radius: 8px;
|
||||
.aside-logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.aside-menu {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.icon-text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
|
||||
text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.aside-footer {
|
||||
height: 48px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 18px;
|
||||
|
||||
.toggle-icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-aside.collapsed {
|
||||
.menu-item {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.aside-item.active{ background:#1677ff; }
|
||||
.aside-icon{ width:22px; height:22px; }
|
||||
.aside-title{ color:#fff; font-size:12px; }
|
||||
</style>
|
||||
|
||||
89
layouts/admin/components/AdminAside_old.uvue.bak
Normal file
89
layouts/admin/components/AdminAside_old.uvue.bak
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<view
|
||||
class="aside"
|
||||
:style="{ width: asideWidth + 'px' }"
|
||||
>
|
||||
<view class="aside-header">
|
||||
<view class="logo">
|
||||
<text class="logo-text"> 商城后台</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="aside-menu">
|
||||
<view
|
||||
v-for="m in menuList"
|
||||
:key="m.id"
|
||||
class="aside-item"
|
||||
:class="{ active: activeMenuId === m.id }"
|
||||
@click="$emit('menu-click', m.id)"
|
||||
>
|
||||
<image class="aside-icon" :src="m.icon" mode="aspectFit" />
|
||||
<text class="aside-title" v-if="!collapsed">{{ m.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import type { MenuItem } from '../types.uts'
|
||||
|
||||
defineProps<{
|
||||
collapsed: boolean
|
||||
menuList: MenuItem[]
|
||||
activeMenuId: string
|
||||
asideWidth:number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e:'menu-click', menuId: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.aside{
|
||||
background: #1f2a37;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
}
|
||||
.aside-header{
|
||||
height: 56px;
|
||||
display:flex;
|
||||
flex-direction:row;
|
||||
align-items:center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.logo-text{
|
||||
color:#fff;
|
||||
font-size:20px;
|
||||
font-weight:600;
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
text-align:center;
|
||||
}
|
||||
.collapse-btn{ width:28px; height:28px; display:flex; align-items:center; justify-content:center; }
|
||||
.collapse-text{ color:rgba(255,255,255,0.7); }
|
||||
|
||||
.aside-menu{ padding: 8px 0; }
|
||||
.aside-item{
|
||||
height: 54px;
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap: 6px;
|
||||
margin: 6px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.aside-item.active{ background:#1677ff; }
|
||||
.aside-icon{ width:22px; height:22px; }
|
||||
.aside-title{ color:#fff; font-size:12px; }
|
||||
</style>
|
||||
@@ -1,43 +1,23 @@
|
||||
<template>
|
||||
<view
|
||||
class="sub-sider"
|
||||
:style="{ left: props.asideWidth + 'px', width: props.siderWidth + 'px' }"
|
||||
>
|
||||
<view class="sub-header">
|
||||
<text class="sub-title">{{ activeMenuTitle }}</text>
|
||||
<view class="admin-subsider" :style="{ left: asideWidth + 'px', width: siderWidth + 'px' }">
|
||||
<view class="subsider-header">
|
||||
<text class="header-title">{{ topMenuTitle }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="sub-body" scroll-y="true">
|
||||
<view
|
||||
v-for="g in groups"
|
||||
:key="groupKey(g)"
|
||||
class="group"
|
||||
>
|
||||
<!-- ✅ 改:点击标题时,如果该组没有 children 但有 path,则当成“叶子菜单”直接跳 -->
|
||||
<view class="group-title" @click="handleGroupTitleClick(g)">
|
||||
<text class="group-title-text">{{ g.title }}</text>
|
||||
|
||||
<!-- ✅ 改:只有有 children 才显示箭头 -->
|
||||
<text
|
||||
v-if="groupChildren(g).length > 0"
|
||||
class="group-arrow"
|
||||
:class="{ open: isGroupOpen(groupKey(g)) }"
|
||||
>v</text>
|
||||
<scroll-view class="subsider-menu" scroll-y="true">
|
||||
<view v-for="group in groups" :key="group.id" class="menu-group">
|
||||
<view class="group-title">
|
||||
<text>{{ group.title }}</text>
|
||||
</view>
|
||||
|
||||
<!-- ✅ 改:v-show 的条件不变,但 children 的遍历必须兜底 -->
|
||||
<view v-show="isGroupOpen(groupKey(g))" class="group-children">
|
||||
<view
|
||||
v-for="c in groupChildren(g)"
|
||||
: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
|
||||
v-for="route in getGroupRoutes(group.id)"
|
||||
:key="route.id"
|
||||
class="menu-item"
|
||||
:class="{ active: route.id === activeRouteId }"
|
||||
@click="onRouteClick(route.id)"
|
||||
>
|
||||
<text class="item-title">{{ route.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -45,352 +25,107 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed, watch, withDefaults, onMounted } from 'vue'
|
||||
import type { MenuGroup, MenuChild } from '../types.uts'
|
||||
import type { MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
activeMenuTitle: string
|
||||
const props = defineProps<{
|
||||
topMenuTitle: string
|
||||
groups: MenuGroup[]
|
||||
activeSubId: string
|
||||
activeMenuId?: string | null
|
||||
routes: Map<string, RouteRecord[]>
|
||||
activeRouteId: string
|
||||
asideWidth: number
|
||||
siderWidth: number
|
||||
}>(), {
|
||||
activeMenuId: 'home',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'sub-click', child: MenuChild): void
|
||||
}>()
|
||||
|
||||
/** 只展开一个分组(更像 CRMEB) */
|
||||
const openGroupKey = ref<string>('')
|
||||
const emit = defineEmits<{
|
||||
(e: 'route-click', routeId: string): void
|
||||
}>()
|
||||
|
||||
/** 给 group 一个稳定 key:优先用 id(如果你 types 里没有 id,就用 title) */
|
||||
const groupKey = (g: MenuGroup): string => {
|
||||
// @ts-ignore
|
||||
const anyG: any = g as any
|
||||
return (anyG.id && (anyG.id as string)) ? (anyG.id as string) : g.title
|
||||
function getGroupRoutes(groupId: string): RouteRecord[] {
|
||||
return props.routes.get(groupId) || []
|
||||
}
|
||||
|
||||
/** ✅ 核心:group.children 兜底成 [](因为 children 现在可选/可空) */
|
||||
const groupChildren = (g: MenuGroup): MenuChild[] => {
|
||||
// @ts-ignore
|
||||
const anyG: any = g as any
|
||||
// @ts-ignore
|
||||
const list = anyG.children as (MenuChild[] | null | undefined)
|
||||
return list && list.length > 0 ? list : []
|
||||
function onRouteClick(routeId: string): void {
|
||||
emit('route-click', routeId)
|
||||
}
|
||||
|
||||
/** ✅ 兼容四级:child.children 兜底成 [] */
|
||||
const childChildren = (c: MenuChild): MenuChild[] => {
|
||||
// @ts-ignore
|
||||
const anyC: any = c as any
|
||||
// @ts-ignore
|
||||
const list = anyC.children as (MenuChild[] | null | undefined)
|
||||
return list && list.length > 0 ? list : []
|
||||
}
|
||||
|
||||
/** ✅ 叶子 group(无 children)如果有 path,则构造一个“伪 MenuChild”用于 emit 跳转 */
|
||||
const groupAsChild = (g: MenuGroup): MenuChild | null => {
|
||||
// @ts-ignore
|
||||
const anyG: any = g as any
|
||||
// @ts-ignore
|
||||
const p = anyG.path as (string | null | undefined)
|
||||
if (!p) return null
|
||||
return { id: groupKey(g), title: g.title, path: p } as MenuChild
|
||||
}
|
||||
|
||||
const normalizePath = (p: string): string => {
|
||||
if (!p) return ''
|
||||
const s = p.startsWith('/') ? p.slice(1) : p
|
||||
const q = s.indexOf('?')
|
||||
return q >= 0 ? s.slice(0, q) : s
|
||||
}
|
||||
|
||||
/** 扁平查找:id -> child(✅ 改:遍历时用 groupChildren/childChildren) */
|
||||
const findChildById = (id: string): MenuChild | null => {
|
||||
if (!id) return null
|
||||
|
||||
for (const g of props.groups) {
|
||||
// ✅ 叶子 group 也能命中
|
||||
const gLeaf = groupAsChild(g)
|
||||
if (gLeaf && gLeaf.id === id) return gLeaf
|
||||
|
||||
for (const c of groupChildren(g)) {
|
||||
if (c.id === id) return c
|
||||
const deep = childChildren(c)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchInChildren(id, deep)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findFirstMatchInChildren = (id: string, list: MenuChild[]): MenuChild | null => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n
|
||||
const deep = childChildren(n)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchInChildren(id, deep)
|
||||
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) {
|
||||
// ✅ 叶子 group 也能按 path 命中
|
||||
const gLeaf = groupAsChild(g)
|
||||
if (gLeaf && normalizePath(gLeaf.path) === target) return gLeaf
|
||||
|
||||
for (const c of groupChildren(g)) {
|
||||
if (normalizePath(c.path) === target) return c
|
||||
const deep = childChildren(c)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchByPath(target, deep)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findFirstMatchByPath = (targetNorm: string, list: MenuChild[]): MenuChild | null => {
|
||||
for (const n of list) {
|
||||
if (normalizePath(n.path) === targetNorm) return n
|
||||
const deep = childChildren(n)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchByPath(targetNorm, deep)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 找到某个 child 属于哪个 group,用于自动展开该组(✅ 改:遍历兜底) */
|
||||
const findGroupKeyByChildId = (id: string): string => {
|
||||
for (const g of props.groups) {
|
||||
const gLeaf = groupAsChild(g)
|
||||
if (gLeaf && gLeaf.id === id) return groupKey(g)
|
||||
|
||||
for (const c of groupChildren(g)) {
|
||||
if (c.id === id) return groupKey(g)
|
||||
const deep = childChildren(c)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchInChildren(id, deep)
|
||||
if (hit) return groupKey(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return props.groups.length > 0 ? groupKey(props.groups[0]) : ''
|
||||
}
|
||||
|
||||
/** 递归取第一个 leaf 菜单(✅ 改:children 兜底) */
|
||||
const firstLeaf = (): MenuChild | null => {
|
||||
if (!props.groups || props.groups.length === 0) return null
|
||||
const g0 = props.groups[0]
|
||||
const g0Children = groupChildren(g0)
|
||||
|
||||
// ✅ 如果第一个 group 是叶子(无 children 但有 path),也能返回
|
||||
if (g0Children.length === 0) {
|
||||
return groupAsChild(g0)
|
||||
}
|
||||
|
||||
const c0 = g0Children[0]
|
||||
const deep = childChildren(c0)
|
||||
if (deep.length > 0) return findFirstLeafInChildren(deep)
|
||||
return c0
|
||||
}
|
||||
|
||||
const findFirstLeafInChildren = (list: MenuChild[]): MenuChild | null => {
|
||||
if (!list || list.length === 0) return null
|
||||
const n = list[0]
|
||||
const deep = childChildren(n)
|
||||
if (deep.length > 0) return findFirstLeafInChildren(deep)
|
||||
return n
|
||||
}
|
||||
|
||||
/** 高亮兜底:按 activeSubId -> route/path 再匹配一次 */
|
||||
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
|
||||
}
|
||||
|
||||
/** ✅ 新增:点 group 标题时的行为 */
|
||||
const handleGroupTitleClick = (g: MenuGroup) => {
|
||||
const list = groupChildren(g)
|
||||
// 有 children:正常展开/收起
|
||||
if (list.length > 0) {
|
||||
toggleGroup(groupKey(g))
|
||||
return
|
||||
}
|
||||
// 无 children:如果有 path,当成叶子菜单直接跳
|
||||
const leaf = groupAsChild(g)
|
||||
if (leaf) {
|
||||
openGroupKey.value = groupKey(g)
|
||||
emit('sub-click', leaf)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (c: MenuChild) => {
|
||||
openGroupKey.value = findGroupKeyByChildId(c.id)
|
||||
emit('sub-click', c)
|
||||
}
|
||||
|
||||
/** 自动:groups 变更/activeSubId 无效时,只做状态同步(展开对应 group),不触发导航 */
|
||||
const ensureDefault = () => {
|
||||
if (!props.groups || props.groups.length === 0) return
|
||||
|
||||
const hit = findChildById(props.activeSubId)
|
||||
if (hit) { openGroupKey.value = findGroupKeyByChildId(hit.id); return }
|
||||
|
||||
// ✅ 再按当前 route 找一次
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
// @ts-ignore
|
||||
const cur: any = pages[pages.length - 1]
|
||||
const route: string = cur?.route ?? ''
|
||||
const byRoute = findChildByPath(route)
|
||||
if (byRoute) { openGroupKey.value = findGroupKeyByChildId(byRoute.id); return }
|
||||
} catch(e) {}
|
||||
|
||||
const first = firstLeaf()
|
||||
if (first) openGroupKey.value = findGroupKeyByChildId(first.id)
|
||||
}
|
||||
|
||||
// ✅ 移除 watch(immediate: true) 中的自动 emit,仅在 groups/activeSubId 变更时同步状态
|
||||
watch(
|
||||
() => props.groups,
|
||||
() => { ensureDefault() },
|
||||
{ immediate: false, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activeSubId,
|
||||
() => { ensureDefault() },
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// ✅ 初始化时只做一次状态同步(不通过 watch immediate)
|
||||
onMounted(() => {
|
||||
ensureDefault()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 原样保留你的样式 */
|
||||
.sub-sider{
|
||||
background:#ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
height: 100vh;
|
||||
<style scoped lang="scss">
|
||||
.admin-subsider {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 900;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.sub-header{
|
||||
height: 56px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.sub-title{
|
||||
font-size:16px;
|
||||
font-weight:700;
|
||||
color:#111827;
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
.subsider-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-body{
|
||||
height: calc(100vh - 56px);
|
||||
.subsider-menu {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
.menu-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.group-title-text{
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color:#111827;
|
||||
}
|
||||
|
||||
.group-arrow{
|
||||
.group-title {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
.group-arrow.open{
|
||||
transform: rotate(180deg);
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
|
||||
text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.menu-item {
|
||||
height: 36px;
|
||||
padding: 0 16px 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
396
layouts/admin/components/AdminSubsider_old.uvue.bak
Normal file
396
layouts/admin/components/AdminSubsider_old.uvue.bak
Normal file
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<view
|
||||
class="sub-sider"
|
||||
: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"
|
||||
>
|
||||
<!-- ✅ 改:点击标题时,如果该组没有 children 但有 path,则当成“叶子菜单”直接跳 -->
|
||||
<view class="group-title" @click="handleGroupTitleClick(g)">
|
||||
<text class="group-title-text">{{ g.title }}</text>
|
||||
|
||||
<!-- ✅ 改:只有有 children 才显示箭头 -->
|
||||
<text
|
||||
v-if="groupChildren(g).length > 0"
|
||||
class="group-arrow"
|
||||
:class="{ open: isGroupOpen(groupKey(g)) }"
|
||||
>v</text>
|
||||
</view>
|
||||
|
||||
<!-- ✅ 改:v-show 的条件不变,但 children 的遍历必须兜底 -->
|
||||
<view v-show="isGroupOpen(groupKey(g))" class="group-children">
|
||||
<view
|
||||
v-for="c in groupChildren(g)"
|
||||
: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, withDefaults, onMounted } from 'vue'
|
||||
import type { MenuGroup, MenuChild } from '../types.uts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
activeMenuTitle: string
|
||||
groups: MenuGroup[]
|
||||
activeSubId: string
|
||||
activeMenuId?: string | null
|
||||
asideWidth: number
|
||||
siderWidth: number
|
||||
}>(), {
|
||||
activeMenuId: 'home',
|
||||
})
|
||||
|
||||
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
|
||||
const anyG: any = g as any
|
||||
return (anyG.id && (anyG.id as string)) ? (anyG.id as string) : g.title
|
||||
}
|
||||
|
||||
/** ✅ 核心:group.children 兜底成 [](因为 children 现在可选/可空) */
|
||||
const groupChildren = (g: MenuGroup): MenuChild[] => {
|
||||
// @ts-ignore
|
||||
const anyG: any = g as any
|
||||
// @ts-ignore
|
||||
const list = anyG.children as (MenuChild[] | null | undefined)
|
||||
return list && list.length > 0 ? list : []
|
||||
}
|
||||
|
||||
/** ✅ 兼容四级:child.children 兜底成 [] */
|
||||
const childChildren = (c: MenuChild): MenuChild[] => {
|
||||
// @ts-ignore
|
||||
const anyC: any = c as any
|
||||
// @ts-ignore
|
||||
const list = anyC.children as (MenuChild[] | null | undefined)
|
||||
return list && list.length > 0 ? list : []
|
||||
}
|
||||
|
||||
/** ✅ 叶子 group(无 children)如果有 path,则构造一个“伪 MenuChild”用于 emit 跳转 */
|
||||
const groupAsChild = (g: MenuGroup): MenuChild | null => {
|
||||
// @ts-ignore
|
||||
const anyG: any = g as any
|
||||
// @ts-ignore
|
||||
const p = anyG.path as (string | null | undefined)
|
||||
if (!p) return null
|
||||
return { id: groupKey(g), title: g.title, path: p } as MenuChild
|
||||
}
|
||||
|
||||
const normalizePath = (p: string): string => {
|
||||
if (!p) return ''
|
||||
const s = p.startsWith('/') ? p.slice(1) : p
|
||||
const q = s.indexOf('?')
|
||||
return q >= 0 ? s.slice(0, q) : s
|
||||
}
|
||||
|
||||
/** 扁平查找:id -> child(✅ 改:遍历时用 groupChildren/childChildren) */
|
||||
const findChildById = (id: string): MenuChild | null => {
|
||||
if (!id) return null
|
||||
|
||||
for (const g of props.groups) {
|
||||
// ✅ 叶子 group 也能命中
|
||||
const gLeaf = groupAsChild(g)
|
||||
if (gLeaf && gLeaf.id === id) return gLeaf
|
||||
|
||||
for (const c of groupChildren(g)) {
|
||||
if (c.id === id) return c
|
||||
const deep = childChildren(c)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchInChildren(id, deep)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findFirstMatchInChildren = (id: string, list: MenuChild[]): MenuChild | null => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n
|
||||
const deep = childChildren(n)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchInChildren(id, deep)
|
||||
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) {
|
||||
// ✅ 叶子 group 也能按 path 命中
|
||||
const gLeaf = groupAsChild(g)
|
||||
if (gLeaf && normalizePath(gLeaf.path) === target) return gLeaf
|
||||
|
||||
for (const c of groupChildren(g)) {
|
||||
if (normalizePath(c.path) === target) return c
|
||||
const deep = childChildren(c)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchByPath(target, deep)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findFirstMatchByPath = (targetNorm: string, list: MenuChild[]): MenuChild | null => {
|
||||
for (const n of list) {
|
||||
if (normalizePath(n.path) === targetNorm) return n
|
||||
const deep = childChildren(n)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchByPath(targetNorm, deep)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 找到某个 child 属于哪个 group,用于自动展开该组(✅ 改:遍历兜底) */
|
||||
const findGroupKeyByChildId = (id: string): string => {
|
||||
for (const g of props.groups) {
|
||||
const gLeaf = groupAsChild(g)
|
||||
if (gLeaf && gLeaf.id === id) return groupKey(g)
|
||||
|
||||
for (const c of groupChildren(g)) {
|
||||
if (c.id === id) return groupKey(g)
|
||||
const deep = childChildren(c)
|
||||
if (deep.length > 0) {
|
||||
const hit = findFirstMatchInChildren(id, deep)
|
||||
if (hit) return groupKey(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return props.groups.length > 0 ? groupKey(props.groups[0]) : ''
|
||||
}
|
||||
|
||||
/** 递归取第一个 leaf 菜单(✅ 改:children 兜底) */
|
||||
const firstLeaf = (): MenuChild | null => {
|
||||
if (!props.groups || props.groups.length === 0) return null
|
||||
const g0 = props.groups[0]
|
||||
const g0Children = groupChildren(g0)
|
||||
|
||||
// ✅ 如果第一个 group 是叶子(无 children 但有 path),也能返回
|
||||
if (g0Children.length === 0) {
|
||||
return groupAsChild(g0)
|
||||
}
|
||||
|
||||
const c0 = g0Children[0]
|
||||
const deep = childChildren(c0)
|
||||
if (deep.length > 0) return findFirstLeafInChildren(deep)
|
||||
return c0
|
||||
}
|
||||
|
||||
const findFirstLeafInChildren = (list: MenuChild[]): MenuChild | null => {
|
||||
if (!list || list.length === 0) return null
|
||||
const n = list[0]
|
||||
const deep = childChildren(n)
|
||||
if (deep.length > 0) return findFirstLeafInChildren(deep)
|
||||
return n
|
||||
}
|
||||
|
||||
/** 高亮兜底:按 activeSubId -> route/path 再匹配一次 */
|
||||
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
|
||||
}
|
||||
|
||||
/** ✅ 新增:点 group 标题时的行为 */
|
||||
const handleGroupTitleClick = (g: MenuGroup) => {
|
||||
const list = groupChildren(g)
|
||||
// 有 children:正常展开/收起
|
||||
if (list.length > 0) {
|
||||
toggleGroup(groupKey(g))
|
||||
return
|
||||
}
|
||||
// 无 children:如果有 path,当成叶子菜单直接跳
|
||||
const leaf = groupAsChild(g)
|
||||
if (leaf) {
|
||||
openGroupKey.value = groupKey(g)
|
||||
emit('sub-click', leaf)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (c: MenuChild) => {
|
||||
openGroupKey.value = findGroupKeyByChildId(c.id)
|
||||
emit('sub-click', c)
|
||||
}
|
||||
|
||||
/** 自动:groups 变更/activeSubId 无效时,只做状态同步(展开对应 group),不触发导航 */
|
||||
const ensureDefault = () => {
|
||||
if (!props.groups || props.groups.length === 0) return
|
||||
|
||||
const hit = findChildById(props.activeSubId)
|
||||
if (hit) { openGroupKey.value = findGroupKeyByChildId(hit.id); return }
|
||||
|
||||
// ✅ 再按当前 route 找一次
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
// @ts-ignore
|
||||
const cur: any = pages[pages.length - 1]
|
||||
const route: string = cur?.route ?? ''
|
||||
const byRoute = findChildByPath(route)
|
||||
if (byRoute) { openGroupKey.value = findGroupKeyByChildId(byRoute.id); return }
|
||||
} catch(e) {}
|
||||
|
||||
const first = firstLeaf()
|
||||
if (first) openGroupKey.value = findGroupKeyByChildId(first.id)
|
||||
}
|
||||
|
||||
// ✅ 移除 watch(immediate: true) 中的自动 emit,仅在 groups/activeSubId 变更时同步状态
|
||||
watch(
|
||||
() => props.groups,
|
||||
() => { ensureDefault() },
|
||||
{ immediate: false, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activeSubId,
|
||||
() => { ensureDefault() },
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// ✅ 初始化时只做一次状态同步(不通过 watch immediate)
|
||||
onMounted(() => {
|
||||
ensureDefault()
|
||||
})
|
||||
</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;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.sub-title{
|
||||
font-size:16px;
|
||||
font-weight:700;
|
||||
color:#111827;
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
.sub-body{
|
||||
height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.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>
|
||||
76
layouts/admin/components/PlaceholderPage.uvue
Normal file
76
layouts/admin/components/PlaceholderPage.uvue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<view class="placeholder-page">
|
||||
<view class="placeholder-content">
|
||||
<view class="placeholder-icon">📦</view>
|
||||
<text class="placeholder-title">{{ title || '页面开发中' }}</text>
|
||||
<text class="placeholder-desc">{{ desc || '该功能模块正在开发中,敬请期待' }}</text>
|
||||
<view class="placeholder-info">
|
||||
<text class="info-label">组件Key:</text>
|
||||
<text class="info-value">{{ componentKey || 'Unknown' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
desc?: string
|
||||
componentKey?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.placeholder-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.placeholder-info {
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #999;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #1890ff;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user