完善页面布局

This commit is contained in:
2026-02-06 10:14:46 +08:00
parent 7a75ab7df4
commit 9eaf5c1a64
28 changed files with 1166 additions and 177 deletions

View File

@@ -22,14 +22,15 @@
<!-- 二级侧边栏 (1:1 复刻 CRMEB 抽屉/Dock 平滑切换) -->
<AdminSubSider
:visible="isSubSiderVisible"
:class="{ 'sub-sider-overlay': isSubSiderOverlay || layoutMode === 'mobile' }"
:topMenuTitle="activeTopMenuTitle"
:groups="activeGroups"
:routes="activeRoutes"
:activeRouteId="activeRouteId"
:isOverlay="isOverlayVisible || layoutMode === 'mobile'"
:layoutMode="layoutMode"
:menuTitle="activeTopMenuTitle"
:menuTree="activeMenuTree"
:activeId="activeRouteId"
:currentPath="currentRoutePath"
:asideWidth="layoutMode === 'mobile' ? 0 : ASIDE_W"
:siderWidth="SUB_W"
@route-click="onRouteClick"
:width="SUB_W"
@sub-click="onSubClick"
/>
<!-- 右侧内容区 -->
@@ -86,9 +87,12 @@ import {
} from '@/layouts/admin/router/adminRoutes.uts'
import type { TopMenu, MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
import { MenuNode, settingSubSiderMenu } from '@/layouts/admin/router/settingSubSiderMenu.uts'
import {
activeTopMenuId,
activeRouteId,
lastSubIdByMenu,
tabs,
isMainAsideCollapsed,
showSubSider,
@@ -123,20 +127,66 @@ const isSubSiderOverlay = computed<boolean>(() => {
return layoutMode.value === 'tablet'
})
// 当前路由的路径
const currentRoutePath = computed<string>(() => {
const route = findRouteById(activeRouteId.value)
return route ? route.path : ''
})
// 将旧的 Group + Routes 转换为新的 Tree 结构 (支持 3 级嵌套)
const activeMenuTree = computed<MenuNode[]>(() => {
if (activeTopMenuId.value === 'setting') {
return settingSubSiderMenu
}
const tree: MenuNode[] = []
activeGroups.value.forEach(group => {
const routes = activeRoutes.value.get(group.id)
if (routes != null && routes.length > 0) {
const children: MenuNode[] = []
routes.forEach(route => {
children.push({
id: route.id,
title: route.title,
type: 'page',
path: route.path
} as MenuNode)
})
// 1:1 复刻 CRMEB: 如果分组标题为空,直接将子菜单平铺在顶层,不渲染分组行
if (group.title === '') {
children.forEach(child => {
tree.push(child)
})
} else {
tree.push({
id: group.id,
title: group.title,
type: 'group',
children: children
} as MenuNode)
}
}
})
return tree
})
/**
* 核心逻辑:二级菜单是否可见
* 1. 如果有子菜单内容 (activeGroups > 0)
* 1. 如果有子菜单内容 (activeMenuTree > 0)
* 2. 如果是 Desktop 且 showSubSider 为真 (Dock模式)
* 3. 如果是 Tablet/Mobile 且 isOverlayVisible 为真 (Overlay模式)
*/
const isSubSiderVisible = computed<boolean>(() => {
if (activeGroups.value.length === 0) return false
// 首页模块通常不显示 SubSider
if (activeTopMenuId.value === 'home' || activeMenuTree.value.length === 0) return false
if (layoutMode.value === 'desktop') {
return showSubSider.value
}
// Tablet 和 Mobile 模式下,作为 Overlay 受 isOverlayVisible 控制
// Tablet 和 Mobile 模式下,直接由 isOverlayVisible 控制
return isOverlayVisible.value
})
@@ -155,7 +205,7 @@ const mainLeft = computed<string>(() => {
let left = ASIDE_W // 只要不是 Mobile主侧栏 70px 始终 Dock
// 只有在 Desktop 模式且二级菜单处于 Dock 模式显示时,才累加宽度
if (layoutMode.value === 'desktop' && showSubSider.value && activeGroups.value.length > 0) {
if (layoutMode.value === 'desktop' && showSubSider.value && activeMenuTree.value.length > 0) {
left += SUB_W
}
@@ -199,6 +249,24 @@ const currentComponent = computed<any>(() => {
return getComponent(route.componentKey)
})
// 监听路由变化,同步状态 (处理从 Tabs 或外部跳转的情况)
watch(() => activeRouteId.value, (newId) => {
const route = findRouteById(newId)
if (route && route.parentId) {
// 同步一级菜单
if (activeTopMenuId.value !== route.parentId) {
activeTopMenuId.value = route.parentId
}
// 同步最后访问记录
lastSubIdByMenu.value[route.parentId] = newId
// 如果是 Desktop 且 SubSider 关着,自动打开(除非用户手动关了)
if (layoutMode.value === 'desktop' && !showSubSider.value) {
showSubSider.value = true
}
}
}, { immediate: true })
// ============================================
// 事件处理
// ============================================
@@ -206,27 +274,46 @@ const currentComponent = computed<any>(() => {
function onTopMenuClick(menu: TopMenu): void {
activeTopMenuId.value = menu.id
// 1:1 复刻 CRMEB 交互:
// 1. 如果有子菜单Desktop 下 dockTablet/Mobile 下唤起 Overlay
if (menu.groups.length > 0) {
if (layoutMode.value === 'desktop') {
showSubSider.value = true
} else {
isOverlayVisible.value = true
}
// 1:1 复刻 CRMEB 交互:点击 Aside 立即展示 SubSider
if (layoutMode.value === 'desktop') {
showSubSider.value = true
} else {
// 2. 如果没有子菜单:直接跳转并关闭所有 Overlay
isOverlayVisible.value = true
isMobileMenuOpen.value = false // 如果主侧栏开着,关掉它,开二级
}
// 1:1 复刻 CRMEB 交互:点击一级菜单后,自动跳转到上一次记录的子页面或第一个子页面
let targetId = lastSubIdByMenu.value[menu.id]
if (!targetId && activeMenuTree.value.length > 0) {
const firstNode = activeMenuTree.value[0]
if (firstNode.type === 'page') {
targetId = firstNode.id
} else if (firstNode.children != null && firstNode.children!.length > 0) {
targetId = firstNode.children![0].id
}
}
if (targetId) {
openRoute(targetId)
} else {
// 兜底逻辑:如果没有二级菜单,跳转到 index
openRoute(menu.id + '_index')
closeAllMenu()
}
}
function onRouteClick(routeId: string): void {
openRoute(routeId)
// 1:1 复刻 CRMEB在移动端或平板叠加模式下点击具体子路由后自动收起
if (layoutMode.value !== 'desktop') {
closeAllMenu()
}
function onSubClick(payload: { id: string, path: string }): void {
// CRMEB 跳转逻辑
openRoute(payload.id)
// 更新最后访问记录
lastSubIdByMenu.value[activeTopMenuId.value] = payload.id
// 重要1:1 复刻 CRMEB点击二级菜单项后SubSider 通常保持显示状态
// 只有在 Mobile 模式下,用户如果是想“跳转并收起”,通常需要点击遮罩或关闭按钮,但这里我们按桌面版常驻逻辑
// 如果需要移动端点击自动关闭,才解开下面注释
// if (layoutMode.value === 'mobile') {
// closeAllMenu()
// }
}
function onTabClick(tab: TabItem): void {

View File

@@ -109,45 +109,34 @@ function onLogoClick(): void {
.aside-menu {
flex: 1;
padding: 8px 0;
padding: 0; /* CRMEB typically no padding here */
overflow-y: scroll;
}
.menu-item {
height: 60px;
height: 50px; /* 1:1 CRMEB columnsAside height */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
color: rgba(255, 255, 255, 0.65);
color: rgba(255, 255, 255, 0.7);
transition: all 0.3s;
position: relative;
&:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
&.active {
background: #1890ff;
background: #1890ff; /* CRMEB 主色蓝 */
color: #fff;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #fff;
}
}
}
.menu-icon {
font-size: 24px;
margin-bottom: 4px;
font-size: 18px; /* CRMEB icons are smaller than 24px usually */
margin-bottom: 2px;
.icon-text {
display: block;
@@ -156,6 +145,7 @@ function onLogoClick(): void {
.menu-title {
font-size: 12px;
transform: scale(0.9); /* CRMEB text is tiny */
text-align: center;
text {

View File

@@ -1,57 +1,160 @@
<template>
<view
class="admin-subsider"
:class="{ 'is-hidden': !visible }"
:style="{ left: asideWidth + 'px', width: siderWidth + 'px' }"
:class="{ 'is-hidden': !visible, 'sub-sider-overlay': isOverlay || layoutMode === 'mobile' }"
:style="{ left: asideWidth + 'px', width: width + 'px' }"
>
<view class="subsider-header">
<text class="header-title">{{ topMenuTitle }}</text>
<!-- 头部模块标题 (1:1 复刻 CRMEB) -->
<view v-if="menuTitle" class="subsider-cat-name">
<text class="cat-title">{{ menuTitle }}</text>
</view>
<view class="subsider-menu">
<view v-for="group in groups" :key="group.id" class="menu-group">
<view class="group-title">
<text>{{ group.title }}</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">
{{ isOpen(level1.id, 1) ? '▼' : '▶' }}
</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 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">
{{ isOpen(level2.id, 2) ? '▼' : '▶' }}
</text>
</view>
<!-- 三级子菜单容器 -->
<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>
</template>
</view>
</template>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import type { MenuGroup, RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
import { ref, reactive, watch, onMounted } from 'vue'
import type { MenuNode } from '../router/settingSubSiderMenu.uts'
const props = defineProps<{
visible: boolean // ✅ 新增:由父层控制显示/隐藏(不要用 v-if 卸载)
topMenuTitle: string
groups: MenuGroup[]
routes: Map<string, RouteRecord[]>
activeRouteId: string
visible: boolean
menuTitle: string
menuTree: MenuNode[]
activeId: string
currentPath: string
asideWidth: number
siderWidth: number
width: number
isOverlay?: boolean
layoutMode?: string
}>()
const emit = defineEmits<{
(e: 'route-click', routeId: string): void
(e: 'sub-click', payload: { id: string, path: string }): void
}>()
function getGroupRoutes(groupId: string): RouteRecord[] {
return props.routes.get(groupId) || []
// --- 展开状态管理 ---
const openIdsByLevel = reactive<Record<number, string>>({
1: '',
2: '',
3: ''
})
/** 判断项是否激活 (页面级) */
function isActive(node: MenuNode): boolean {
// 只有非分组项才能被视为“激活”变色 (1:1 复刻 screenshot 效果)
if (node.type === 'group') return false
if (props.activeId != '' && node.id === props.activeId) return true
if (props.currentPath != '' && node.path === props.currentPath) return true
return false
}
function onRouteClick(routeId: string): void {
emit('route-click', routeId)
/** 判断分组是否展开 */
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">
@@ -59,118 +162,102 @@ function onRouteClick(routeId: string): void {
position: fixed;
top: 0;
bottom: 0;
background: #fff;
border-right: 1px solid #e8e8e8;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
background: #ffffff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
/* ✅ 动画只用 GPU 友好属性,避免 width/left 导致卡顿 */
opacity: 1;
transform: translate3d(0, 0, 0);
will-change: transform, opacity;
/* ✅ 可点击时才需要高 z-index */
z-index: 999;
/* ✅ 注意visibility 默认可见 */
visibility: visible;
/* 显示态visibility 不延迟 */
transition: transform 300ms ease, opacity 250ms ease, visibility 0s linear 0s;
}
/* 悬浮/抽屉模式:复刻 CRMEB 核心逻辑 */
.sub-sider-overlay {
z-index: 1001;
box-shadow: 6px 0 16px rgba(0, 0, 0, 0.08);
z-index: 1000;
transition: transform 300ms ease, opacity 250ms ease;
}
.admin-subsider.is-hidden {
/* ✅ 立即禁止拦截点击,彻底解决“遮挡可操作区” */
pointer-events: none;
/* ✅ 先淡出 + 滑出(推荐滑出 100%:完全离开可视区) */
opacity: 0;
transform: translate3d(-100%, 0, 0);
/* ✅ 动画结束后再隐藏(避免那一帧还挡住) */
visibility: hidden;
transition: transform 300ms ease, opacity 250ms ease, visibility 0s linear 300ms;
/* 可选:进一步避免层叠影响 */
z-index: 0;
opacity: 0;
pointer-events: none;
}
.subsider-header {
height: 64px;
.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 16px;
border-bottom: 1px solid #e8e8e8;
.header-title {
font-size: 16px;
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-menu {
.subsider-content {
flex: 1;
padding: 8px 0;
/* ✅ 用 auto避免一直显示滚动条影响布局 */
overflow-y: auto;
}
.menu-group {
margin-bottom: 16px;
.menu-list {
padding: 0; /* 移除 8px 边距,确保菜单项紧贴顶部/标题栏 */
}
.group-title {
padding: 8px 16px;
font-size: 12px;
color: #999;
font-weight: 500;
text {
display: block;
}
}
.menu-item {
height: 36px;
padding: 0 16px 0 24px;
.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; /* 保持布局稳定 */
/* ✅ 不要 transition: all会引发布局属性动画/回流) */
transition: background-color 0.2s ease, color 0.2s ease;
&:hover {
background: #f5f5f5;
}
&.active {
background: #e6f7ff;
color: #1890ff;
&::after {
content: '';
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 3px;
background: #1890ff;
&.is-active {
background-color: #e7f0ff;
.menu-text {
color: #2f65ff;
font-weight: 500;
}
}
.item-title {
.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;
}
.sub-menu-container {
overflow: hidden;
}
</style>

View File

@@ -123,10 +123,25 @@ export const componentMap: Map<string, any> = new Map([
// 数据模块 - 暂时使用占位组件
['StatisticIndex', PlaceholderPage],
// 设置模块 - 暂时使用占位组件
// 设置模块
['SettingSystemConfig', defineAsyncComponent(() => import('@/pages/mall/admin/setting/system/config.uvue'))],
['SettingSystemAdmin', PlaceholderPage],
['SettingSystemRole', PlaceholderPage],
['SettingMessage', defineAsyncComponent(() => import('@/pages/mall/admin/setting/message.uvue'))],
['SettingAgreement', defineAsyncComponent(() => import('@/pages/mall/admin/setting/agreement.uvue'))],
['SettingTicket', defineAsyncComponent(() => import('@/pages/mall/admin/setting/ticket.uvue'))],
['SettingAuthRole', defineAsyncComponent(() => import('@/pages/mall/admin/setting/auth/role.uvue'))],
['SettingAuthAdmin', defineAsyncComponent(() => import('@/pages/mall/admin/setting/auth/admin.uvue'))],
['SettingAuthPermission', defineAsyncComponent(() => import('@/pages/mall/admin/setting/auth/permission.uvue'))],
['SettingDeliveryStaff', defineAsyncComponent(() => import('@/pages/mall/admin/setting/delivery/staff.uvue'))],
['SettingDeliveryStation', defineAsyncComponent(() => import('@/pages/mall/admin/setting/delivery/station.uvue'))],
['SettingDeliveryTemplate', defineAsyncComponent(() => import('@/pages/mall/admin/setting/delivery/template.uvue'))],
['SettingInterfaceOnepassConfig', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/onepass/config.uvue'))],
['SettingInterfaceOnepassIndex', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/onepass/index.uvue'))],
['SettingInterfaceStorage', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/storage.uvue'))],
['SettingInterfaceCollect', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/collect.uvue'))],
['SettingInterfaceLogistics', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/logistics.uvue'))],
['SettingInterfaceESheet', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/e-sheet.uvue'))],
['SettingInterfaceSms', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/sms.uvue'))],
['SettingInterfacePayment', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/payment.uvue'))],
// 分销模块
['DistributionStatistic', PlaceholderPage],

View File

@@ -92,7 +92,7 @@ export const topMenus: TopMenu[] = [
path: '/pages/mall/admin/product/statistic',
order: 4,
groups: [
{ id: 'product-manage', title: '商品管理', order: 1 }
{ id: 'product-manage', title: '', order: 1 }
]
},
{
@@ -156,7 +156,7 @@ export const topMenus: TopMenu[] = [
path: '/pages/mall/admin/cms/article/list',
order: 9,
groups: [
{ id: 'cms-manage', title: '内容管理', order: 1 }
{ id: 'cms-manage', title: '', order: 1 }
]
},
{
@@ -187,7 +187,10 @@ export const topMenus: TopMenu[] = [
order: 12,
groups: [
{ id: 'setting-system', title: '系统设置', order: 1 },
{ id: 'setting-application', title: '应用设置', order: 2 }
{ id: 'setting-message', title: '通知管理', order: 2 },
{ id: 'setting-auth', title: '权限管理', order: 3 },
{ id: 'setting-delivery', title: '物流设置', order: 4 },
{ id: 'setting-interface', title: '接口设置', order: 5 }
]
},
{
@@ -892,6 +895,7 @@ export const routes: RouteRecord[] = [
},
// ========== 设置模块 ==========
// 1. 系统设置
{
id: 'setting_systemConfig',
title: '系统设置',
@@ -902,27 +906,168 @@ export const routes: RouteRecord[] = [
auth: ['admin-setting-system-config'],
order: 1
},
// 2. 通知管理
{
id: 'setting_systemAdmin',
title: '管理员管理',
path: '/pages/mall/admin/setting/system/admin',
componentKey: 'SettingSystemAdmin',
id: 'setting_message',
title: '消息管理',
path: '/pages/mall/admin/setting/message',
componentKey: 'SettingMessage',
parentId: 'setting',
groupId: 'setting-system',
auth: ['admin-setting-system-admin'],
groupId: 'setting-message',
order: 1
},
{
id: 'setting_agreement',
title: '协议管理',
path: '/pages/mall/admin/setting/agreement',
componentKey: 'SettingAgreement',
parentId: 'setting',
groupId: 'setting-message',
order: 2
},
{
id: 'setting_systemRole',
title: '角色管理',
path: '/pages/mall/admin/setting/system/role',
componentKey: 'SettingSystemRole',
id: 'setting_ticket',
title: '客服设置',
path: '/pages/mall/admin/setting/ticket',
componentKey: 'SettingTicket',
parentId: 'setting',
groupId: 'setting-system',
auth: ['admin-setting-system-role'],
groupId: 'setting-message',
order: 3
},
// 3. 权限管理
{
id: 'setting_auth_role',
title: '角色管理',
path: '/pages/mall/admin/setting/auth/role',
componentKey: 'SettingAuthRole',
parentId: 'setting',
groupId: 'setting-auth',
order: 1
},
{
id: 'setting_auth_admin',
title: '管理员管理',
path: '/pages/mall/admin/setting/auth/admin',
componentKey: 'SettingAuthAdmin',
parentId: 'setting',
groupId: 'setting-auth',
order: 2
},
{
id: 'setting_auth_permission',
title: '权限管理',
path: '/pages/mall/admin/setting/auth/permission',
componentKey: 'SettingAuthPermission',
parentId: 'setting',
groupId: 'setting-auth',
order: 3
},
// 4. 物流设置
{
id: 'setting_delivery_staff',
title: '配送员管理',
path: '/pages/mall/admin/setting/delivery/staff',
componentKey: 'SettingDeliveryStaff',
parentId: 'setting',
groupId: 'setting-delivery',
order: 1
},
{
id: 'setting_delivery_station',
title: '提货点管理',
path: '/pages/mall/admin/setting/delivery/station',
componentKey: 'SettingDeliveryStation',
parentId: 'setting',
groupId: 'setting-delivery',
order: 2
},
{
id: 'setting_delivery_template',
title: '运费模板',
path: '/pages/mall/admin/setting/delivery/template',
componentKey: 'SettingDeliveryTemplate',
parentId: 'setting',
groupId: 'setting-delivery',
order: 3
},
// 5. 接口设置
{
id: 'setting_interface_onepass_config',
title: '总平台配置',
path: '/pages/mall/admin/setting/interface/onepass/config',
componentKey: 'SettingInterfaceOnepassConfig',
parentId: 'setting',
groupId: 'setting-interface',
order: 1
},
{
id: 'setting_interface_onepass_index',
title: '账号列表',
path: '/pages/mall/admin/setting/interface/onepass/index',
componentKey: 'SettingInterfaceOnepassIndex',
parentId: 'setting',
groupId: 'setting-interface',
order: 2
},
{
id: 'setting_interface_storage',
title: '存储配置',
path: '/pages/mall/admin/setting/interface/storage',
componentKey: 'SettingInterfaceStorage',
parentId: 'setting',
groupId: 'setting-interface',
order: 3
},
{
id: 'setting_interface_collect',
title: '商品采集',
path: '/pages/mall/admin/setting/interface/collect',
componentKey: 'SettingInterfaceCollect',
parentId: 'setting',
groupId: 'setting-interface',
order: 4
},
{
id: 'setting_interface_logistics',
title: '物流查询',
path: '/pages/mall/admin/setting/interface/logistics',
componentKey: 'SettingInterfaceLogistics',
parentId: 'setting',
groupId: 'setting-interface',
order: 5
},
{
id: 'setting_interface_esheet',
title: '电子面单',
path: '/pages/mall/admin/setting/interface/e-sheet',
componentKey: 'SettingInterfaceESheet',
parentId: 'setting',
groupId: 'setting-interface',
order: 6
},
{
id: 'setting_interface_sms',
title: '短信接口',
path: '/pages/mall/admin/setting/interface/sms',
componentKey: 'SettingInterfaceSms',
parentId: 'setting',
groupId: 'setting-interface',
order: 7
},
{
id: 'setting_interface_payment',
title: '商城支付',
path: '/pages/mall/admin/setting/interface/payment',
componentKey: 'SettingInterfacePayment',
parentId: 'setting',
groupId: 'setting-interface',
order: 8
},
// ========== 分销模块 ==========
{
id: 'distribution_statistic',
@@ -1055,7 +1200,7 @@ export const routes: RouteRecord[] = [
order: 6
},
{
id: 'decoration_link',
id: 'DecorationLink',
title: '链接管理',
path: '/pages/mall/admin/decoration/link',
componentKey: 'DecorationLink',
@@ -1064,6 +1209,30 @@ export const routes: RouteRecord[] = [
order: 7
},
// ========== 设置模块 (1:1 复刻 CRMEB 路由结构) ==========
// 通知管理
{ id: 'setting_message_index', title: '消息管理', path: '/pages/mall/admin/setting/message/index', componentKey: 'SettingMessageIndex', parentId: 'setting', groupId: 'setting-message', order: 1 },
{ id: 'setting_protocol_index', title: '协议设置', path: '/pages/mall/admin/setting/protocol/index', componentKey: 'SettingProtocolIndex', parentId: 'setting', groupId: 'setting-message', order: 2 },
{ id: 'setting_ticket_index', title: '小票配置', path: '/pages/mall/admin/setting/ticket/index', componentKey: 'SettingTicketIndex', parentId: 'setting', groupId: 'setting-message', order: 3 },
// 权限管理
{ id: 'setting_auth_role', title: '角色管理', path: '/pages/mall/admin/setting/auth/role/index', componentKey: 'SettingAuthRole', parentId: 'setting', groupId: 'setting-auth', order: 1 },
{ id: 'setting_auth_admin', title: '管理员列表', path: '/pages/mall/admin/setting/auth/admin-list/index', componentKey: 'SettingAuthAdmin', parentId: 'setting', groupId: 'setting-auth', order: 2 },
{ id: 'setting_auth_perm', title: '权限设置', path: '/pages/mall/admin/setting/auth/permission/index', componentKey: 'SettingAuthPerm', parentId: 'setting', groupId: 'setting-auth', order: 3 },
// 物流设置
{ id: 'setting_delivery_courier', title: '配送员管理', path: '/pages/mall/admin/setting/delivery/courier/index', componentKey: 'SettingDeliveryCourier', parentId: 'setting', groupId: 'setting-delivery', order: 1 },
{ id: 'setting_delivery_pickup', title: '提货点设置', path: '/pages/mall/admin/setting/delivery/pickup/index', componentKey: 'SettingDeliveryPickup', parentId: 'setting', groupId: 'setting-delivery', order: 2 },
{ id: 'setting_delivery_freight', title: '运费模板', path: '/pages/mall/admin/setting/delivery/freight/index', componentKey: 'SettingDeliveryFreight', parentId: 'setting', groupId: 'setting-delivery', order: 3 },
// 接口设置
{ id: 'setting_api_storage', title: '系统存储配置', path: '/pages/mall/admin/setting/api/yht/storage/index', componentKey: 'SettingApiStorage', parentId: 'setting', groupId: 'setting-interface', order: 1 },
{ id: 'setting_api_collect', title: '商品采集配置', path: '/pages/mall/admin/setting/api/yht/collect/index', componentKey: 'SettingApiCollect', parentId: 'setting', groupId: 'setting-interface', order: 2 },
{ id: 'setting_api_logistics', title: '物流查询配置', path: '/pages/mall/admin/setting/api/yht/logistics/index', componentKey: 'SettingApiLogistics', parentId: 'setting', groupId: 'setting-interface', order: 3 },
{ id: 'setting_api_waybill', title: '电子面单配置', path: '/pages/mall/admin/setting/api/yht/waybill/index', componentKey: 'SettingApiWaybill', parentId: 'setting', groupId: 'setting-interface', order: 4 },
{ id: 'setting_api_sms', title: '短信接口配置', path: '/pages/mall/admin/setting/api/yht/sms/index', componentKey: 'SettingApiSms', parentId: 'setting', groupId: 'setting-interface', order: 5 },
{ id: 'setting_api_pay', title: '商城支付配置', path: '/pages/mall/admin/setting/api/yht/pay/index', componentKey: 'SettingApiPay', parentId: 'setting', groupId: 'setting-interface', order: 6 },
// ========== 应用模块 ==========
{
id: 'app_statistic',

View File

@@ -0,0 +1,37 @@
export type MenuNode = {
id: string
title: string
type: 'group' | 'page'
path?: string // type=page 必填
children?: MenuNode[] // type=group 必填
}
export const settingSubSiderMenu: MenuNode[] = [
{ id: 'setting_message_index', title: '消息管理', type: 'page', path: '/pages/mall/admin/setting/message/index' },
{ id: 'setting_protocol_index', title: '协议设置', type: 'page', path: '/pages/mall/admin/setting/protocol/index' },
{ id: 'setting_ticket_index', title: '小票配置', type: 'page', path: '/pages/mall/admin/setting/ticket/index' },
{ id: 'auth_group', title: '管理权限', type: 'group', children: [
{ id: 'setting_auth_role', title: '角色管理', type: 'page', path: '/pages/mall/admin/setting/auth/role/index' },
{ id: 'setting_auth_admin', title: '管理员列表', type: 'page', path: '/pages/mall/admin/setting/auth/admin-list/index' },
{ id: 'setting_auth_perm', title: '权限设置', type: 'page', path: '/pages/mall/admin/setting/auth/permission/index' }
]
},
{ id: 'delivery_group', title: '发货设置', type: 'group', children: [
{ id: 'setting_delivery_courier', title: '配送员管理', type: 'page', path: '/pages/mall/admin/setting/delivery/courier/index' },
{ id: 'setting_delivery_pickup', title: '提货点设置', type: 'page', path: '/pages/mall/admin/setting/delivery/pickup/index' },
{ id: 'setting_delivery_freight', title: '运费模板', type: 'page', path: '/pages/mall/admin/setting/delivery/freight/index' }
]
},
{ id: 'api_group', title: '接口配置', type: 'group', children: [
{ id: 'yh_tong', title: '一号通', type: 'group', children: [
{ id: 'setting_api_storage', title: '系统存储配置', type: 'page', path: '/pages/mall/admin/setting/api/yht/storage/index' },
{ id: 'setting_api_collect', title: '商品采集配置', type: 'page', path: '/pages/mall/admin/setting/api/yht/collect/index' },
{ id: 'setting_api_logistics', title: '物流查询配置', type: 'page', path: '/pages/mall/admin/setting/api/yht/logistics/index' },
{ id: 'setting_api_waybill', title: '电子面单配置', type: 'page', path: '/pages/mall/admin/setting/api/yht/waybill/index' },
{ id: 'setting_api_sms', title: '短信接口配置', type: 'page', path: '/pages/mall/admin/setting/api/yht/sms/index' },
{ id: 'setting_api_pay', title: '商城支付配置', type: 'page', path: '/pages/mall/admin/setting/api/yht/pay/index' }
]
}
]
}
]

View File

@@ -11,6 +11,7 @@ import {
buildDefaultTabs,
getTopMenus
} from '@/layouts/admin/router/adminRoutes.uts'
import { addView, activeFullPath, visitedViews } from './tagsViewStore.uts'
/**
* 标签页类型
@@ -32,6 +33,12 @@ export const activeTopMenuId = ref<string>('home')
/** 当前激活的路由ID */
export const activeRouteId = ref<string>('home_index')
/** 记录每个一级模块上一次访问的二级路由ID (CRMEB 体验增强) */
export const lastSubIdByMenu = ref<Record<string, string>>({})
/** 标记是否由用户手动关闭了 SubSider (移动端) */
export const isManualClosed = ref<boolean>(false)
/** 打开的标签页列表 */
export const tabs = ref<TabItem[]>([])
@@ -82,6 +89,8 @@ export function openRoute(routeId: string, addTab: boolean = true): void {
// 更新一级菜单选中态
if (route.parentId) {
activeTopMenuId.value = route.parentId
// 记录该模块最后访问的子路由
lastSubIdByMenu.value[route.parentId] = routeId
} else {
// 首页等顶级路由
activeTopMenuId.value = routeId.split('_')[0]
@@ -116,6 +125,10 @@ function addTabItem(route: RouteRecord): void {
isAffix: route.isAffix || false
})
}
// 更新新版 TagsViewStore
addView(route, route.path)
activeFullPath.value = route.path
}
/**
@@ -201,6 +214,11 @@ export function initNavState(): void {
isAffix: r.isAffix || false
}))
// 初始化 TagsViewStore
defaultTabs.forEach(r => {
addView(r, r.path)
})
// 打开首页
openRoute('home_index', false)
}

View File

@@ -0,0 +1,126 @@
/**
* TagsView 状态管理
* 复刻 CRMEB 风格的标签栏逻辑
*/
import { ref } from 'vue'
import type { RouteRecord } from '@/layouts/admin/router/adminRoutes.uts'
/**
* 标签页视图类型
*/
export type TagView = {
id: string // 对应路由ID
title: string // 标题
path: string // 基础路径
fullPath: string // 完整路径(包含参数)
name: string // 组件Key
meta: {
affix?: boolean
noCache?: boolean
}
}
/** 访问过的页面列表 */
export const visitedViews = ref<TagView[]>([])
/** 缓存的页面列表 (用于 keep-alive) */
export const cachedViews = ref<string[]>([])
/** 开启的标签页详情 */
export const activeFullPath = ref<string>('')
// ============================================
// Actions
// ============================================
/**
* 添加视图
*/
export function addView(route: RouteRecord, fullPath: string): void {
// 1. 添加到访问列表
if (visitedViews.value.some(v => v.fullPath === fullPath)) return
// 如果 ID 相同但 fullPath 不同(参数变化), 则更新或者替换
const existingIndex = visitedViews.value.findIndex(v => v.id === route.id)
const newView: TagView = {
id: route.id,
title: route.title,
path: route.path,
fullPath: fullPath,
name: route.componentKey,
meta: {
affix: route.isAffix || false,
noCache: false // 默认为缓存,如果有特殊需求可扩展
}
}
if (existingIndex > -1) {
// 同一路由不同参数, 更新记录
visitedViews.value[existingIndex] = newView
} else {
visitedViews.value.push(newView)
}
// 2. 添加到缓存列表
if (!route.componentKey) return
if (cachedViews.value.includes(route.componentKey)) return
cachedViews.value.push(route.componentKey)
}
/**
* 删除视图
*/
export function delView(view: TagView): void {
const index = visitedViews.value.findIndex(v => v.fullPath === view.fullPath)
if (index > -1 && !visitedViews.value[index].meta.affix) {
visitedViews.value.splice(index, 1)
}
const cacheIndex = cachedViews.value.indexOf(view.name)
if (cacheIndex > -1) {
cachedViews.value.splice(cacheIndex, 1)
}
}
/**
* 关闭其他标签
*/
export function delOthersViews(view: TagView): void {
visitedViews.value = visitedViews.value.filter(v => v.meta.affix || v.fullPath === view.fullPath)
cachedViews.value = visitedViews.value.map(v => v.name)
}
/**
* 关闭右侧标签
*/
export function delRightTags(view: TagView): void {
const index = visitedViews.value.findIndex(v => v.fullPath === view.fullPath)
if (index === -1) return
visitedViews.value = visitedViews.value.filter((v, i) => {
return i <= index || v.meta.affix
})
cachedViews.value = visitedViews.value.map(v => v.name)
}
/**
* 关闭所有
*/
export function delAllViews(): void {
const affixTags = visitedViews.value.filter(v => v.meta.affix)
visitedViews.value = affixTags
cachedViews.value = affixTags.map(v => v.name)
}
/**
* 刷新当前视图 (通常结合全局事件通知组件)
*/
export function refreshView(view: TagView): void {
const index = cachedViews.value.indexOf(view.name)
if (index > -1) {
cachedViews.value.splice(index, 1)
}
// 通知框架重新加载该组件的逻辑通常在组件内部处理或通过 key 变化
}