feat: 综合管理后台扁平化 + 历史按 portal+app 隔离

- admin-portal 从多应用形态重构为单应用形态:3个一级菜单(系统设置/运营中心/设备中心)直接挂在 portal 下,URL=/admin-portal/<section>/<page>
- TopBar 移除应用切换下拉,应用名改为纯文本展示
- PageHistory 隔离粒度从 portal 级改为 portal+app 级(scopeKey 形如 admin-portal__admin / tenant-portal__nutrition)
- Home 原型首页区分 admin 一级菜单卡片与 tenant 应用卡片布局,tenant 新增"应用主页"入口卡片
- nav.ts 用 adminSections + getAdminVirtualApp() 替代原 adminApps 占位应用数组

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fengpu
2026-06-05 16:54:58 +08:00
co-authored by Claude Opus 4.7
parent c64072f125
commit 2727cfda86
10 changed files with 445 additions and 141 deletions
+10 -9
View File
@@ -9,22 +9,23 @@
---
## 四个终端
| 终端 | key | UI 库 | 主要用户 | 业务定位 |
|------|-----|-------|---------|---------|
| 综合管理后台 | `admin-portal` | Ant Design Vue | 平台管理员 | 平台级管理(系统设置/运营中心/设备中心 等独立应用 |
| 租户运营后台 | `tenant-portal` | Ant Design Vue | 租户运营人员 | **17 个独立可部署应用的统一入口**(营养管理/健康监测/... |
| 综合管理后台 | `admin-portal` | Ant Design Vue | 平台管理员 | 平台级管理(系统设置/运营中心/设备中心 为顶部一级菜单,**无子应用、无应用切换、无应用网格** |
| 租户运营后台 | `tenant-portal` | Ant Design Vue | 租户运营人员 | **17 个独立可部署应用的统一入口**(营养管理/健康监测/...,有应用网格首页 |
| C 端微信小程序 | `miniprogram` | Vant 4375×812) | 终端用户 | 用户自助查询、上报、咨询 |
| 硬件终端屏 | `hardware` | 自定义(动态 viewport) | 现场操作员 | 净菜柜屏 / 自助查询屏 / 大屏 等 |
> **重要架构说明**对齐生产 vue 项目 `D:\Work\platform-vue-tenant` 的 qiankun 微前端形态):
> - `admin-portal` / `tenant-portal` 只是登录门户外壳。下挂多个**独立可部署的应用(NavApp)**,每个应用对应生产 vue 项目中的一个 `subXxx` 子应用
> - 进入某个应用后,顶部显示**该应用内的一级菜单 tabs**NavSection),左侧显示**当前一级菜单下的二/三级菜单**NavGroup → NavLeaf
> - URL 结构:`/<portal>/<app>/<section>/<page>`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop`
> - 应用首页(`/<portal>`):展示应用网格卡片(参考 vue 项目 main 的 `home.vue`)。
> **重要架构说明**admin / tenant 形态差异):
> - **综合管理后台为单应用形态**:3 个一级菜单(系统设置 / 运营中心 / 设备中心)直接挂在 portal 下,URL = `/admin-portal/<section>/<page>`,无应用切换概念
> - **租户运营后台为多应用形态**(对齐生产 vue 项目 `D:\Work\platform-vue-tenant` 的 qiankun 微前端):下挂多个**独立可部署的应用(NavApp)**,每个应用对应生产 vue 项目中的一个 `subXxx` 子应用
> - 进入某个应用后,顶部显示**该应用内的一级菜单 tabs**NavSection),左侧显示**当前一级菜单下的二/三级菜单**NavGroup → NavLeaf
> - URL`/tenant-portal/<app>/<section>/<page>`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop`
> - 应用首页(`/tenant-portal`):展示应用网格卡片(参考 vue 项目 main 的 `home.vue`
> - 不支持在 TopBar 切换应用;切换应用通过"应用首页"或"返回原型首页"完成
> - **最近打开菜单(PageHistory)按 portal + app 隔离**scope key 形如 `admin-portal__admin` / `tenant-portal__nutrition`;切换应用后只显示当前应用打开过的菜单。
> 详细业务边界与各应用模块清单见 **`src/docs/business-architecture.md`**。AI 在生成原型前**必须先读这份文档**。
---
## 技术栈
+34 -21
View File
@@ -2,7 +2,10 @@
* 页面访问历史(最近打开的菜单 Tabs)
* - 对齐生产 vue 项目 D:\Work\platform-vue-tenant\apps\subNutritionManagement\src\store\pageHistory
* - 实现方式:composable + localStorage(项目不引入 Pinia
* - 作用域:每个 portal 独立维护一份历史(key: pageHistory.<portalKey>
* - 作用域:按 <portal>__<app> 隔离一份历史
* · admin-portal__admin
* · tenant-portal__nutrition / tenant-portal__monitor / ...
* 切换应用后只显示当前应用打开过的菜单
*/
import type { Router } from 'vue-router'
@@ -23,44 +26,44 @@ export interface PageHistoryItem {
const STORAGE_KEY_PREFIX = 'pageHistory.'
const readFromStorage = (portalKey: string): PageHistoryItem[] => {
const readFromStorage = (scopeKey: string): PageHistoryItem[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY_PREFIX + portalKey)
const raw = localStorage.getItem(STORAGE_KEY_PREFIX + scopeKey)
return raw ? (JSON.parse(raw) as PageHistoryItem[]) : []
} catch {
return []
}
}
const writeToStorage = (portalKey: string, list: PageHistoryItem[]): void => {
localStorage.setItem(STORAGE_KEY_PREFIX + portalKey, JSON.stringify(list))
const writeToStorage = (scopeKey: string, list: PageHistoryItem[]): void => {
localStorage.setItem(STORAGE_KEY_PREFIX + scopeKey, JSON.stringify(list))
}
/** 各 portal 的响应式历史列表 */
/** 各 scope 的响应式历史列表 */
const stores = new Map<string, ReturnType<typeof ref<PageHistoryItem[]>>>()
const getStore = (portalKey: string) => {
let s = stores.get(portalKey)
const getStore = (scopeKey: string) => {
let s = stores.get(scopeKey)
if (!s) {
s = ref<PageHistoryItem[]>(readFromStorage(portalKey))
stores.set(portalKey, s)
s = ref<PageHistoryItem[]>(readFromStorage(scopeKey))
stores.set(scopeKey, s)
}
return s
}
/**
* 使用页面历史(无数量上限,对齐 vue 项目)
* @param portalKey 'admin-portal' | 'tenant-portal'
* @param scopeKey 形如 'admin-portal__admin' / 'tenant-portal__nutrition'
*/
export const usePageHistory = (portalKey: string) => {
const list = getStore(portalKey)
export const usePageHistory = (scopeKey: string) => {
const list = getStore(scopeKey)
/** 新增页面(已存在则前移到末尾) */
const addPage = (item: PageHistoryItem): void => {
const arr = (list.value ?? []).filter(i => i.path !== item.path)
arr.push(item)
list.value = arr
writeToStorage(portalKey, arr)
writeToStorage(scopeKey, arr)
}
/** 移除指定路径 */
@@ -70,7 +73,7 @@ export const usePageHistory = (portalKey: string) => {
if (idx === -1) return null
arr.splice(idx, 1)
list.value = [...arr]
writeToStorage(portalKey, list.value)
writeToStorage(scopeKey, list.value)
// 返回下一个应跳转的路径(关闭当前页时用)
return list.value[list.value.length - 1]?.path ?? null
}
@@ -78,21 +81,29 @@ export const usePageHistory = (portalKey: string) => {
/** 关闭除指定路径外的所有 */
const closeOthers = (path: string): void => {
list.value = (list.value ?? []).filter(i => i.path === path)
writeToStorage(portalKey, list.value)
writeToStorage(scopeKey, list.value)
}
/** 清空 */
const clear = (): void => {
list.value = []
writeToStorage(portalKey, [])
writeToStorage(scopeKey, [])
}
return { list, addPage, removePage, closeOthers, clear }
}
/**
* 在 router.afterEach 钩子里自动追踪某个 portal 的访问
* - 仅当路由 meta.portal 匹配时记录
* 计算路由对应的历史 scope key
* - admin 路由 meta.app 固定为 'admin'
* - tenant 路由 meta.app 为具体应用 keynutrition / monitor / ...
*/
const resolveScopeKey = (portal: string, app: string | undefined): string =>
`${portal}__${app ?? 'default'}`
/**
* 在 router.afterEach 钩子里自动追踪访问
* - 仅当路由 meta.portal 存在时记录
* - 忽略应用网格首页 / 重定向路由
*/
export const installPageHistoryTracker = (router: Router): void => {
@@ -101,11 +112,13 @@ export const installPageHistoryTracker = (router: Router): void => {
if (!portal) return
if (to.meta?.isAppGrid) return
if (!to.name) return
const { addPage } = usePageHistory(portal)
const app = to.meta?.app as string | undefined
const scopeKey = resolveScopeKey(portal, app)
const { addPage } = usePageHistory(scopeKey)
addPage({
path: to.path,
title: String(to.meta?.label ?? to.name ?? to.path),
app: to.meta?.app as string | undefined,
app,
section: to.meta?.section as string | undefined,
leafKey: String(to.name),
})
+21 -10
View File
@@ -2,7 +2,7 @@
> **作用**:这份文档供 AI 在生成原型时参考,确保理解业务上下文、不串端、不重复造功能。
> **维护规则**:用户在规划每个端的具体功能模块时填写本文档,AI 在 new-page / gen-prd / cross-role-doc-builder 等任务中**必须先读这份文档**。
> _Last updated: 2026-06-04 / 阶段 16(用户提供四端粗粒度功能清单_
> _Last updated: 2026-06-05 / 阶段 17(综合后台扁平化、租户后台去应用切换、历史按 portal+app 隔离_
---
@@ -10,21 +10,32 @@
### 1.1 综合管理后台 `admin-portal`
| 子端 | 一级模块 | 说明 |
|------|---------|------|
| 系统设置 | (待规划) | 平台层基础设置:组织、账号、权限、字典、参数 |
| 运营中心 | (待规划) | 平台运营视角:租户管理、应用配置、计费、统计 |
| 设备中心 | (待规划) | 跨租户硬件接入与运维:设备注册、固件版本、在线状态 |
> **形态**:单应用形态。**没有子应用概念**,3 个一级菜单(系统设置 / 运营中心 / 设备中心)直接挂在 portal 下。
> **URL 模式**`/admin-portal/<section>/<page>`
> **UI 表现**:左上角显示固定标题"综合管理后台",**无应用切换下拉**、**无应用网格首页**、**无"返回应用首页"快捷入口**;顶部一级菜单 tabs 即三大模块。
| 一级模块 | 说明 |
|---------|------|
| 系统设置 | 平台层基础设置:组织、账号、权限、字典、参数 |
| 运营中心 | 平台运营视角:租户管理、应用配置、计费、统计 |
| 设备中心 | 跨租户硬件接入与运维:设备注册、固件版本、在线状态 |
> 综合后台是**跨租户**视角;与租户运营后台的边界:凡是只能在租户内部生效的业务(如某租户的食堂菜单),归属租户后台。
### 1.2 租户运营后台 `tenant-portal`
| 一级模块 | 说明 |
> **形态**:多应用形态(对齐生产 vue 项目 qiankun 微前端,每个应用对应一个独立可部署的 subXxx)。
> **URL 模式**`/tenant-portal/<app>/<section>/<page>``/tenant-portal` 为应用网格首页。
> **UI 表现**
> - `/tenant-portal` 为应用网格首页(无侧栏 / 无顶部一级菜单),展示 17 个应用卡片
> - 进入应用后,左上角显示当前应用名(纯文本,**不可点击切换应用**),顶部一级菜单 tabs = 当前应用内的 sections
> - 右上角提供"应用首页"快捷入口回到应用网格、"返回原型首页"回到全局导航
> - 切换应用只能通过"应用首页"或"返回原型首页"
| 一级模块(应用) | 说明 |
|---------|------|
| 租户首页 | 租户运营视角的首页:核心 KPI、待办、快捷入口 |
| 控制台 | 租户运营的控制中心:组织架构、岗位、人员、流程 |
| 系统管理 | 租户级系统管理:账号、角色、字典、日志、消息模板 |
| 控制台 | 租户运营驾驶舱:核心 KPI、待办、快捷入口 |
| 系统管理 | 租户级系统管理:组织、账号、角色、字典、日志、消息模板 |
| 营养管理 | 食堂全链路:种植、采购、加工、菜品、食谱、订餐、出餐、质检 |
| 健康体检 | 体检计划、机构、项目、报告、统计 |
| 体重管理 | 体重档案、采集、目标、干预、提醒 |
+51 -39
View File
@@ -6,11 +6,10 @@
:current-app="isAppGrid ? undefined : currentApp"
:sections="isAppGrid ? [] : currentAppSections"
:current-section="currentSectionKey"
:show-app-grid="!isAppGrid"
:show-app-grid="!isAppGrid && variant === 'tenant'"
:app-grid-path="portalPath"
:portal-title="portalTitle"
@section-change="onSectionChange"
@app-change="onAppChange"
@search="onSearch"
>
<template #extra>
@@ -19,7 +18,7 @@
</TopBar>
<a-layout class="portal-layout__body">
<!-- 应用网格首页不显示侧边栏 / 不显示 PageHistory -->
<!-- 应用网格首页 tenant不显示侧边栏 / 不显示 PageHistory -->
<a-layout-content v-if="isAppGrid" class="portal-layout__content portal-layout__content--full">
<router-view />
</a-layout-content>
@@ -30,7 +29,7 @@
<Sidebar :section="currentSection" :active-key="currentLeafKey" @navigate="onLeafClick" />
</a-layout-sider>
<a-layout class="portal-layout__main">
<PageHistory :portal-key="portalKey" />
<PageHistory :portal-key="historyScopeKey" />
<a-layout-content class="portal-layout__content">
<router-view />
</a-layout-content>
@@ -44,13 +43,10 @@
/**
* 后台门户布局
* - 综合管理后台(admin)与租户运营后台(tenant)共用此 Layout
* - 通过 props.variant 切换菜单数据源、顶部副标题与颜色
* - 两种形态:
* · admin:单应用形态,URL = /admin-portal/<section>/<page>,无应用网格、无应用切换
* · tenant:多应用形态,URL = /tenant-portal/<app>/<section>/<page>,有应用网格首页
* - 自动按当前角色 + 控制台覆写过滤菜单
*
* 层级(对齐 vue 项目):
* Portal -> NavApp (应用) -> NavSection (一级菜单) -> NavGroup/NavLeaf -> NavLeaf
*
* URL/<portal>/<app>/<section>/<page>
*/
import { getPortalApps, isNavGroup, type NavApp, type NavSection, type NavLeaf } from '@meta/nav'
import { useVisibility, filterApps } from '@composables/useVisibility'
@@ -66,7 +62,7 @@ const { role, overrides } = useVisibility()
/** 当前 portal 的 routerPath 前缀 */
const portalPath = computed(() => props.variant === 'admin' ? '/admin-portal' : '/tenant-portal')
/** 当前 portal key(用于 PageHistory 隔离 */
/** 当前 portal key(用于 meta */
const portalKey = computed(() => props.variant === 'admin' ? 'admin-portal' : 'tenant-portal')
/** 当前 portal 的应用列表(按角色 + 覆写过滤) */
@@ -76,18 +72,29 @@ const apps = computed<NavApp[]>(() => {
return filterApps(all, effectiveRole, overrides.value)
})
/** 是否处于应用网格首页(路由 meta.isAppGrid */
const isAppGrid = computed<boolean>(() => Boolean(route.meta.isAppGrid))
/**
* 是否处于应用网格首页
* - admin:永远 false(无应用网格)
* - tenant:路由 meta.isAppGrid
*/
const isAppGrid = computed<boolean>(() =>
props.variant === 'tenant' && Boolean(route.meta.isAppGrid),
)
/** 顶部 portal 标题(用于应用网格首页 logo 区) */
const portalTitle = computed(() =>
props.variant === 'admin' ? '综合管理后台' : '租户运营后台',
)
/** 当前激活应用 key(从 route.meta 取,回退到第一个应用) */
const currentAppKey = computed<string>(() =>
(route.meta.app as string) ?? apps.value[0]?.key ?? '',
)
/**
* 当前激活应用 key
* - admin:固定为虚拟 app 的 key 'admin'
* - tenant:从 route.meta 取
*/
const currentAppKey = computed<string>(() => {
if (props.variant === 'admin') return 'admin'
return (route.meta.app as string) ?? apps.value[0]?.key ?? ''
})
/** 当前激活应用对象 */
const currentApp = computed<NavApp | undefined>(() =>
@@ -110,6 +117,13 @@ const currentSection = computed<NavSection | undefined>(() =>
/** 当前激活叶子页面 key(即路由 name) */
const currentLeafKey = computed<string>(() => (route.name as string) ?? '')
/**
* 历史隔离 scope keyportal + app
* - admin'admin-portal__admin'
* - tenant'tenant-portal__<appKey>'
*/
const historyScopeKey = computed(() => `${portalKey.value}__${currentAppKey.value}`)
/** 找一个 section 的第一个叶子 */
const firstLeafOfSection = (section: NavSection): NavLeaf | undefined => {
const first = section.children[0]
@@ -117,41 +131,39 @@ const firstLeafOfSection = (section: NavSection): NavLeaf | undefined => {
return isNavGroup(first) ? first.children[0] : first
}
/** 找一个 app 的第一个叶子(用于切换应用时跳转) */
const firstLeafOfApp = (app: NavApp): { section: NavSection; leaf: NavLeaf } | undefined => {
for (const s of app.children) {
const leaf = firstLeafOfSection(s)
if (leaf) return { section: s, leaf }
}
return undefined
}
/** 切换顶部一级菜单:跳到该 section 第一个页面 */
/**
* 切换顶部一级菜单:跳到该 section 第一个页面
* - admin/admin-portal/<section>/<page>
* - tenant/tenant-portal/<app>/<section>/<page>
*/
const onSectionChange = (sectionKey: string) => {
const section = currentAppSections.value.find(s => s.key === sectionKey)
if (!section) return
const leaf = firstLeafOfSection(section)
if (!leaf) return
void router.push(`${portalPath.value}/${currentAppKey.value}/${section.key}/${leaf.path}`)
}
/** 切换应用:跳到该应用第一个页面 */
const onAppChange = (appKey: string) => {
const app = apps.value.find(a => a.key === appKey)
if (!app) return
const entry = firstLeafOfApp(app)
if (!entry) return
void router.push(`${portalPath.value}/${app.key}/${entry.section.key}/${entry.leaf.path}`)
if (props.variant === 'admin') {
void router.push(`${portalPath.value}/${section.key}/${leaf.path}`)
} else {
void router.push(`${portalPath.value}/${currentAppKey.value}/${section.key}/${leaf.path}`)
}
}
/** 点击侧边栏叶子:跳路由 */
const onLeafClick = (leaf: NavLeaf) => {
void router.push(`${portalPath.value}/${currentAppKey.value}/${currentSectionKey.value}/${leaf.path}`)
if (props.variant === 'admin') {
void router.push(`${portalPath.value}/${currentSectionKey.value}/${leaf.path}`)
} else {
void router.push(`${portalPath.value}/${currentAppKey.value}/${currentSectionKey.value}/${leaf.path}`)
}
}
/** 顶部搜索菜单:直接跳到目标叶子 */
const onSearch = (leaf: NavLeaf, section: NavSection) => {
void router.push(`${portalPath.value}/${currentAppKey.value}/${section.key}/${leaf.path}`)
if (props.variant === 'admin') {
void router.push(`${portalPath.value}/${section.key}/${leaf.path}`)
} else {
void router.push(`${portalPath.value}/${currentAppKey.value}/${section.key}/${leaf.path}`)
}
}
</script>
+1
View File
@@ -84,6 +84,7 @@ import {
import type { MenuProps } from 'ant-design-vue'
import { usePageHistory } from '@composables/usePageHistory'
/** props.portalKey 实际为 usePageHistory 的 scopeKey(形如 'admin-portal__admin' / 'tenant-portal__nutrition' */
const props = defineProps<{ portalKey: string }>()
const router = useRouter()
+10 -22
View File
@@ -1,23 +1,9 @@
<template>
<header class="top-bar">
<!-- 左侧 Logo + 应用图标 + 应用名对齐 vue 项目 subXxx layouts/header -->
<!-- 左侧 Logo + 应用图标 + 应用名纯文本不可切换 -->
<div class="top-bar__logo">
<span class="top-bar__app-icon">{{ logoIcon }}</span>
<a-dropdown v-if="apps.length > 1 && currentApp" :trigger="['click']">
<span class="top-bar__app-name">
{{ currentApp.label }}
<DownOutlined class="top-bar__app-arrow" />
</span>
<template #overlay>
<a-menu @click="(info) => emit('appChange', String(info.key))">
<a-menu-item v-for="app in apps" :key="app.key">
<span v-if="app.icon" class="app-switch-icon">{{ app.icon }}</span>
<span>{{ app.label }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<span v-else class="top-bar__app-name">{{ currentApp?.label ?? portalTitle ?? '租户运营' }}</span>
<span class="top-bar__app-name">{{ currentApp?.label ?? portalTitle ?? '租户运营' }}</span>
</div>
<!-- 中部应用内一级菜单横向 a-menu, light 主题白底 -->
@@ -70,7 +56,7 @@
<script setup lang="ts">
import type { MenuProps, CascaderProps } from 'ant-design-vue'
import { DownOutlined, HomeOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
import { HomeOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
import { isNavGroup, type NavApp, type NavLeaf, type NavSection, type NavGroup } from '@meta/nav'
/**
@@ -78,13 +64,16 @@ import { isNavGroup, type NavApp, type NavLeaf, type NavSection, type NavGroup }
* 对齐 vue 项目 D:\Work\platform-vue-tenant\apps\subNutritionManagement\src\layouts\header\index.vue
*
* 视觉规范:
* - 左侧 Logo 区:深蓝 #001529,宽 220px,显示应用图标 + 应用名
* - 左侧 Logo 区:深蓝 #001529,宽 200px,显示应用图标 + 应用名(纯文本,不可点击切换)
* - 中部一级菜单:a-menu mode=horizontal theme=light(白底)
* - 菜单搜索:a-cascader(应用内所有菜单的两级联动)
* - 右侧:返回首页 + 应用首页 + 用户区
* - 右侧:返回首页 + 应用首页(仅 tenant 应用内显示)+ 用户区
*
* 注:应用切换不在 TopBar 提供,统一通过"返回应用首页"或"返回原型首页"完成;
* 综合管理后台无应用概念,左上角显示 portal 标题。
*/
const props = defineProps<{
/** 当前 portal 下所有应用(已按角色过滤) */
/** 当前 portal 下所有应用(已按角色过滤;仅用于菜单搜索数据源,不再用于切换 */
apps: NavApp[]
/** 当前激活应用(应用网格首页时为 undefined) */
currentApp?: NavApp
@@ -92,7 +81,7 @@ const props = defineProps<{
sections: NavApp['children']
/** 当前激活一级菜单 key */
currentSection: string
/** 是否显示"应用首页"快捷入口 */
/** 是否显示"应用首页"快捷入口(仅租户应用内显示) */
showAppGrid?: boolean
/** 应用网格首页路径 */
appGridPath?: string
@@ -102,7 +91,6 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'sectionChange', sectionKey: string): void
(e: 'appChange', appKey: string): void
(e: 'search', leaf: NavLeaf, section: NavSection): void
}>()
+76 -15
View File
@@ -4,20 +4,22 @@
* - 新增页面只改这一处,路由 + 菜单同步生效
*
* 四个终端:
* - admin-portal 综合管理后台(AntDV 后台 UI)
* - admin-portal 综合管理后台(AntDV 后台 UI;单应用形态,3 个一级菜单:系统设置 / 运营中心 / 设备中心
* - tenant-portal 租户运营后台(AntDV 后台 UI;下挂 17 个独立应用)
* - miniprogram C 端微信小程序(Vant375×812;吃 / 问 / 做 / 我)
* - hardware 硬件终端屏(按设备类型动态 viewport;10 类设备)
*
* 后台菜单层级(对齐生产 vue 项目 D:\Work\platform-vue-tenant):
* Portal (终端)
* └─ NavApp (应用,对应 vue 项目的 subXxx 子应用,可独立部署)
* └─ NavSection (应用内一级菜单,对应顶部横向 tabs)
* └─ NavGroup (二级分组,可选) 或 NavLeaf (二级菜单/页面)
* └─ NavLeaf (三级页面)
* · admin-portal(无子应用):
* Portal → NavSection (一级菜单) → NavLeaf / NavGroup → NavLeaf
* URL/admin-portal/<section>/<page>
* · tenant-portal(多应用形态):
* Portal → NavApp (应用,对应 vue 项目的 subXxx 子应用) → NavSection (一级菜单)
* → NavGroup (可选) → NavLeaf
* URL/tenant-portal/<app>/<section>/<page>
*
* 图标规范:
* - NavApp.icon:应用图标,可自定义 emoji
* - NavApp.icon:应用图标,可自定义 emoji(仅 tenant 应用网格 / TopBar 左上角显示)
* - NavSection / NavGroup / NavLeaf 的 icon**必须**使用 ant-design-vue 4.2 内置图标组件名
* (例:'FileTextOutlined' / 'AppstoreOutlined' / 'BarChartOutlined'
* - 渲染:layouts 用 `<component :is="icon" />` 动态渲染
@@ -161,15 +163,70 @@ const makePlaceholderApp = (
],
})
// =========================== 综合管理后台 ===========================
// =========================== 综合管理后台(单应用形态) ===========================
/** 综合管理后台应用列表(粗粒度占位) */
export const adminApps: NavApp[] = [
makePlaceholderApp('system-settings', '系统设置', '⚙️', '平台级用户、角色、权限、字典', 'system'),
makePlaceholderApp('operation-center', '运营中心', '📊', '租户管理、版本管理、计费', 'system'),
makePlaceholderApp('device-center', '设备中心', '🖥️', '硬件设备注册、监控、运维', 'system'),
/**
* 综合管理后台占位叶子
* - 与 tenant 的 placeholder 工厂分开,避免误读为"占位应用"
*/
const adminPlaceholderLeaf = (
key: string,
label: string,
path: string,
): NavLeaf => ({
key,
label,
path,
status: 'draft',
component: PlaceholderImport,
})
/**
* 综合管理后台一级菜单(顶部横向 tabs)
* - admin-portal 没有子应用概念,3 个一级菜单直接挂在 portal 下
* - URL/admin-portal/<section>/<page>
*/
export const adminSections: NavSection[] = [
{
key: 'system-settings',
label: '系统设置',
icon: 'SettingOutlined',
children: [
adminPlaceholderLeaf('admin-system-settings-overview', '系统设置首页', 'overview'),
],
},
{
key: 'operation-center',
label: '运营中心',
icon: 'FundProjectionScreenOutlined',
children: [
adminPlaceholderLeaf('admin-operation-center-overview', '运营中心首页', 'overview'),
],
},
{
key: 'device-center',
label: '设备中心',
icon: 'DesktopOutlined',
children: [
adminPlaceholderLeaf('admin-device-center-overview', '设备中心首页', 'overview'),
],
},
]
/**
* 综合管理后台的虚拟 NavApp 包装
* - 仅供路由/布局内部消费(PortalLayout 拿到一个 NavApp 才能复用现有的 sections 渲染逻辑)
* - 不对外暴露"应用"语义,不参与租户应用网格 / 应用切换
*/
export const getAdminVirtualApp = (): NavApp => ({
key: 'admin',
label: '综合管理后台',
icon: '🛠️',
description: '平台级管理(系统设置 / 运营中心 / 设备中心)',
category: 'system',
children: adminSections,
})
// =========================== 租户运营后台 ===========================
/**
@@ -679,7 +736,11 @@ export const isNavGroup = (item: NavLeaf | NavGroup): item is NavGroup => {
// =========================== 工具:按 Portal 取数 ===========================
/** 按 portalKey 取得对应的应用数组(admin / tenant */
/**
* 按 portalKey 取得对应的应用数组(admin / tenant
* - admin:返回一个虚拟 NavApp 包裹 adminSectionsadmin 无子应用概念,包装仅为复用 NavApp 渲染逻辑)
* - tenant:返回真实的 17 个独立应用
*/
export const getPortalApps = (key: 'admin-portal' | 'tenant-portal'): NavApp[] => {
return key === 'admin-portal' ? adminApps : tenantApps
return key === 'admin-portal' ? [getAdminVirtualApp()] : tenantApps
}
+4 -3
View File
@@ -93,11 +93,12 @@
*/
import type { TableColumnsType } from 'ant-design-vue'
import {
adminApps,
getPortalApps,
tenantApps,
miniprogramTabs,
hardwareDevices,
isNavGroup,
type NavApp,
type NavLeaf,
type PortalKey,
} from '@meta/nav'
@@ -141,7 +142,7 @@ const allLeaves = computed<Row[]>(() => {
})
}
const pushApps = (apps: typeof adminApps, portal: PortalKey) => {
const pushApps = (apps: NavApp[], portal: PortalKey) => {
apps.forEach((app) => {
app.children.forEach((section) => {
section.children.forEach((item) => {
@@ -156,7 +157,7 @@ const allLeaves = computed<Row[]>(() => {
})
}
pushApps(adminApps, 'admin-portal')
pushApps(getPortalApps('admin-portal'), 'admin-portal')
pushApps(tenantApps, 'tenant-portal')
miniprogramTabs.forEach((tab) => {
push(tab.page, 'miniprogram', tab.label)
+182 -19
View File
@@ -31,8 +31,79 @@
<!-- 内容区 -->
<main class="home__main">
<!-- 综合管理后台 / 租户运营后台 内容 -->
<div v-if="activeKey === 'admin-portal' || activeKey === 'tenant-portal'" class="module-grid">
<!-- 综合管理后台一级菜单卡片无应用概念 -->
<div v-if="activeKey === 'admin-portal'" class="module-grid">
<div
v-for="card in adminSectionCards"
:key="card.key"
class="module-card"
:class="{
'module-card--ready': card.pageCount > 0 && card.hasReady,
'module-card--wip': card.pageCount > 0 && !card.hasReady,
'module-card--empty': card.pageCount === 0,
}"
@click="enterAdminSection(card)"
>
<div class="module-card__watermark" v-if="card.pageCount === 0">
<div class="module-card__watermark-text">待开发</div>
</div>
<div class="module-card__header">
<div class="module-card__name">{{ card.label }}</div>
<span
class="module-card__status"
:class="[card.hasReady ? 'module-card__status--done' : 'module-card__status--wip']"
v-if="card.pageCount > 0"
>
{{ card.hasReady ? '设计中' : '待开发' }}
</span>
<span class="module-card__status module-card__status--pending" v-else>未开始</span>
</div>
<div class="module-card__bottom">
<a class="module-card__prd" @click.stop="openPrd(card.key)">
📄 PRD
</a>
<span class="module-card__page-count" @click.stop="togglePages(card.key)">
{{ card.pageCount }} 个页面
</span>
</div>
<!-- 悬浮页面清单 -->
<div v-if="openedPagesKey === card.key" class="page-popover page-popover--visible">
<div class="page-popover__title">{{ card.label }} · 页面清单</div>
<a
v-for="leaf in card.leaves"
:key="leaf.key"
class="page-popover__item"
@click.stop="enterAdminLeaf(card.key, leaf)"
>
📄 {{ leaf.label }}
</a>
</div>
</div>
</div>
<!-- 租户运营后台应用主页卡片 + 各应用卡片 -->
<div v-else-if="activeKey === 'tenant-portal'" class="module-grid">
<!-- 应用主页卡片首张 -->
<div
class="module-card module-card--home"
@click="enterTenantAppGrid"
>
<div class="module-card__header">
<div class="module-card__name">
<span class="module-card__emoji">🏠</span>
应用主页
</div>
<span class="module-card__status module-card__status--done">入口</span>
</div>
<div class="module-card__bottom">
<span class="module-card__hint">租户应用网格首页含全部应用卡片</span>
</div>
</div>
<!-- 各应用卡片 -->
<div
v-for="card in portalCards"
:key="card.key"
@@ -76,7 +147,7 @@
v-for="leaf in card.leaves"
:key="leaf.key"
class="page-popover__item"
@click.stop="enterLeaf(activeKey, card.key, leaf)"
@click.stop="enterLeaf('tenant-portal', card.key, leaf)"
>
📄 {{ leaf.label }}
</a>
@@ -194,12 +265,14 @@
*/
import { useRouter } from 'vue-router'
import {
adminApps,
adminSections,
tenantApps,
miniprogramTabs,
hardwareDevices,
isNavGroup,
type NavApp,
type NavSection,
type NavGroup,
type NavLeaf,
type NavMiniprogramTab,
type NavHardwareDevice,
@@ -217,21 +290,30 @@ const { role, overrides } = useVisibility()
const effectiveRole = computed(() => role.value ?? 'designer')
/** 过滤四端数据 */
const filteredAdmin = computed(() => filterApps(adminApps, effectiveRole.value, overrides.value))
const filteredTenant = computed(() => filterApps(tenantApps, effectiveRole.value, overrides.value))
const filteredMpTabs = computed(() => filterMiniprogramTabs(miniprogramTabs, effectiveRole.value, overrides.value))
const filteredHardware = computed(() => filterHardwareDevices(hardwareDevices, effectiveRole.value, overrides.value))
/** 综合管理后台一级菜单(admin 无子应用,直接展示 sections */
const adminAllSections = computed<NavSection[]>(() => adminSections)
/** 一个 section 下所有叶子(含 group 内的) */
const flattenSectionLeaves = (section: NavSection): NavLeaf[] => {
const out: NavLeaf[] = []
section.children.forEach((c) => {
if (isNavGroup(c)) out.push(...(c as NavGroup).children)
else out.push(c as NavLeaf)
})
return out
}
// ============================== Tab 计数 ==============================
/** 把一个应用的所有叶子展平(含分组下的)— 一个应用通常有多个 section,再嵌套 group/leaf */
const flattenAppLeaves = (app: NavApp): NavLeaf[] => {
const out: NavLeaf[] = []
app.children.forEach((section) => {
section.children.forEach((c) => {
if (isNavGroup(c)) out.push(...c.children)
else out.push(c)
})
out.push(...flattenSectionLeaves(section))
})
return out
}
@@ -239,8 +321,11 @@ const flattenAppLeaves = (app: NavApp): NavLeaf[] => {
const countAppPages = (apps: NavApp[]): number =>
apps.reduce((sum, a) => sum + flattenAppLeaves(a).length, 0)
const countSectionPages = (sections: NavSection[]): number =>
sections.reduce((sum, s) => sum + flattenSectionLeaves(s).length, 0)
const tabs = computed(() => [
{ key: 'admin-portal', label: '综合管理后台', icon: '🛠️', count: countAppPages(filteredAdmin.value) },
{ key: 'admin-portal', label: '综合管理后台', icon: '🛠️', count: countSectionPages(adminAllSections.value) },
{ key: 'tenant-portal', label: '租户运营后台', icon: '💼', count: countAppPages(filteredTenant.value) },
{ key: 'miniprogram', label: '微信小程序', icon: '📱',
count: filteredMpTabs.value.reduce((s, t) => s + 1 + (t.subPages?.length ?? 0), 0) },
@@ -264,16 +349,15 @@ interface AppCard {
leaves: NavLeaf[]
}
/** 租户运营后台:应用卡片 */
const portalCards = computed<AppCard[]>(() => {
const apps = activeKey.value === 'admin-portal' ? filteredAdmin.value : filteredTenant.value
return apps.map<AppCard>((app) => {
return filteredTenant.value.map<AppCard>((app) => {
const leaves = flattenAppLeaves(app)
// 找第一个 section + 第一个 leaf
let firstEntry: AppCard['firstEntry'] | undefined
for (const section of app.children) {
const first = section.children[0]
if (!first) continue
const leaf = isNavGroup(first) ? first.children[0] : first
const leaf = isNavGroup(first) ? (first as NavGroup).children[0] : (first as NavLeaf)
if (leaf) { firstEntry = { section: section.key, leaf }; break }
}
return {
@@ -287,6 +371,34 @@ const portalCards = computed<AppCard[]>(() => {
})
})
/** 综合管理后台:一级菜单卡片(每张卡片 = 一个 NavSection */
interface AdminSectionCard {
key: string
label: string
pageCount: number
hasReady: boolean
firstLeaf?: NavLeaf
leaves: NavLeaf[]
}
const adminSectionCards = computed<AdminSectionCard[]>(() => {
return adminAllSections.value.map<AdminSectionCard>((section) => {
const leaves = flattenSectionLeaves(section)
const first = section.children[0]
const firstLeaf = first
? (isNavGroup(first) ? (first as NavGroup).children[0] : (first as NavLeaf))
: undefined
return {
key: section.key,
label: section.label,
pageCount: leaves.length,
hasReady: leaves.some(l => (l.status ?? 'ready') === 'ready'),
firstLeaf,
leaves,
}
})
})
// ============================== 行为 ==============================
const openedPagesKey = ref<string | null>(null)
@@ -296,17 +408,33 @@ const togglePages = (modKey: string) => {
const enterPortal = (card: AppCard) => {
if (!card.firstEntry) return
router.push(`/${activeKey.value}/${card.key}/${card.firstEntry.section}/${card.firstEntry.leaf.path}`)
router.push(`/tenant-portal/${card.key}/${card.firstEntry.section}/${card.firstEntry.leaf.path}`)
}
/** 在悬浮清单里找指定 leaf 所属 section 然后跳转 */
/** 租户:进入应用主页(应用网格首页) */
const enterTenantAppGrid = () => {
router.push('/tenant-portal')
}
/** 综合后台:点击一级菜单卡片 → 跳到该 section 第一个 page */
const enterAdminSection = (card: AdminSectionCard) => {
if (!card.firstLeaf) return
router.push(`/admin-portal/${card.key}/${card.firstLeaf.path}`)
}
/** 综合后台:从悬浮清单点击具体叶子 */
const enterAdminLeaf = (sectionKey: string, leaf: NavLeaf) => {
router.push(`/admin-portal/${sectionKey}/${leaf.path}`)
}
/** 租户:在悬浮清单里找指定 leaf 所属 section 然后跳转 */
const enterLeaf = (portalKey: string, appKey: string, leaf: NavLeaf) => {
const apps = portalKey === 'admin-portal' ? filteredAdmin.value : filteredTenant.value
const apps = filteredTenant.value
const app = apps.find(a => a.key === appKey)
if (!app) return
for (const section of app.children) {
for (const item of section.children) {
const candidates = isNavGroup(item) ? item.children : [item]
const candidates = isNavGroup(item) ? (item as NavGroup).children : [item as NavLeaf]
if (candidates.some(l => l.key === leaf.key)) {
router.push(`/${portalKey}/${appKey}/${section.key}/${leaf.path}`)
return
@@ -392,9 +520,28 @@ const buildPortalSection = (
return { key: portalKey, label, icon, items, readyCount: items.filter(i => i.ready).length }
}
/**
* 综合管理后台的 PRD 区块:以 NavSection(一级菜单)为粒度
* - 无应用概念,PRD 直接按一级菜单组织
* - 约定 PRD 路径:admin-portal/<sectionKey>/_module.md 或 admin-portal/<sectionKey>.md
*/
const buildAdminPrdSection = (sections: NavSection[]): PrdSection => {
const items: PrdItem[] = sections.map((s) => {
const path = resolveModulePrd('admin-portal', s.key)
return { key: s.key, name: s.label, path, ready: !!path }
})
return {
key: 'admin-portal',
label: '综合管理后台',
icon: '🛠️',
items,
readyCount: items.filter(i => i.ready).length,
}
}
const prdSections = computed<PrdSection[]>(() => {
const sections: PrdSection[] = [
buildPortalSection('admin-portal', '综合管理后台', '🛠️', filteredAdmin.value),
buildAdminPrdSection(adminAllSections.value),
buildPortalSection('tenant-portal', '租户运营后台', '💼', filteredTenant.value),
]
@@ -666,6 +813,22 @@ const emojiOf = (icon: string): string => {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
}
// 租户"应用主页"卡片:蓝色主题,强调入口语义
&--home {
border-color: #91caff;
background: linear-gradient(135deg, #e6f4ff 0%, #f5faff 100%);
&:hover {
border-color: #1677ff;
box-shadow: 0 8px 24px rgba(22, 119, 255, 0.18);
}
}
}
.module-card__hint {
font-size: 12px;
color: #5a607f;
}
.module-card__watermark {
+56 -3
View File
@@ -1,12 +1,13 @@
import type { RouteRecordRaw } from 'vue-router'
import {
adminApps,
adminSections,
tenantApps,
miniprogramTabs,
hardwareDevices,
isNavGroup,
type NavApp,
type NavSection,
type NavGroup,
type NavLeaf,
} from '@meta/nav'
@@ -142,6 +143,58 @@ const buildPortalChildren = (apps: NavApp[], portalKey: string): RouteRecordRaw[
return children
}
/**
* 综合管理后台子路由(扁平结构)
* - URL 模式:/admin-portal/<section>/<page>
* - admin 无子应用概念、无应用网格首页
* - 路由 meta.app 固定为 'admin'(用于按 portal+app 隔离 PageHistory
*/
const buildAdminPortalChildren = (): RouteRecordRaw[] => {
const children: RouteRecordRaw[] = []
// 找首个可访问的 section + leaf 用于 /admin-portal 根重定向
let firstSectionKey: string | undefined
let firstLeafPath: string | undefined
for (const section of adminSections) {
const first = section.children[0]
if (!first) continue
const leaf = isNavGroup(first) ? (first as NavGroup).children[0] : (first as NavLeaf)
if (leaf) {
firstSectionKey = section.key
firstLeafPath = leaf.path
break
}
}
if (firstSectionKey && firstLeafPath) {
children.push({
path: '',
redirect: `/admin-portal/${firstSectionKey}/${firstLeafPath}`,
})
}
// 展平所有页面:path = <section>/<page>
adminSections.forEach((section: NavSection) => {
section.children.forEach((item) => {
const leaves: NavLeaf[] = isNavGroup(item) ? (item as NavGroup).children : [item as NavLeaf]
leaves.forEach((leaf) => {
children.push({
path: `${section.key}/${leaf.path}`,
name: leaf.key,
component: leaf.component as RouteRecordRaw['component'],
meta: {
portal: 'admin-portal',
app: 'admin',
section: section.key,
label: leaf.label,
},
} as RouteRecordRaw)
})
})
})
return children
}
/** 全局路由表 */
const routes: RouteRecordRaw[] = [
{
@@ -175,12 +228,12 @@ const routes: RouteRecordRaw[] = [
meta: { label: '原型导航首页' },
},
/** 综合管理后台 */
/** 综合管理后台(扁平结构:/admin-portal/<section>/<page> */
{
path: '/admin-portal',
component: () => import('@layouts/PortalLayout.vue'),
props: { variant: 'admin' },
children: buildPortalChildren(adminApps, 'admin-portal'),
children: buildAdminPortalChildren(),
},
/** 租户运营后台 */