refactor(menu): 侧边栏按决策域重构(L1 九域 + L2 决策场景)+ PDCA 回退开关
ci/woodpecker/push/main Pipeline was successful

- 新增 frontend/src/config/decisionDomains.ts:9 个决策域(100 战略…900 系统)
  与 22 个 L2 决策场景,只写 path 关系;文案/图标/roles 仍取 permission.ts MENU_ITEMS
- MainLayout.vue:决策域菜单为默认(el-sub-menu 域 → el-menu-item-group 场景 → 页面),
  「我的工作台」常驻置顶,系统域默认折叠;未登记菜单项兜底进 900「未归类」(不丢项)
- 回退开关:localStorage.cma_menu_mode='pdca' 或 URL ?menu=pdca,右上角下拉可切换
- 路由 path、页面、API、DB、zone A/B 权限判定一律未改
- components.d.ts 由 unplugin-vue-components 自动补 ElMenuItemGroup 声明
This commit is contained in:
wecom-fullstack
2026-10-18 21:45:32 +08:00
parent 7f36608f60
commit bf641d8d31
3 changed files with 216 additions and 10 deletions
+123
View File
@@ -0,0 +1,123 @@
// ── 决策域菜单配置(L1 决策域 → L2 决策场景 → 页面)────────────────────────────
// 依据:《管理会计应用指引》100–900 九域;财务BOT 派单
// finance-20261018-cma-menu-domain-refactor-fullstack(任总 2026-10-18 21:22/21:25)
//
// 边界(重要):
// 1) 本文件只做**菜单分组**——不改路由 path、不改页面、不改 API、不改 DB;
// 2) 权限仍由 permission.ts 的 MENU_ITEMS.roles 过滤 + ROLE_ROUTES/hasRouteAccess 判定,
// zone A/B 只作权限维度,不作菜单维度;
// 3) 页面条目的文案/图标/roles 一律取自 permission.ts(MENU_ITEMS),本文件只写 path 关系;
// 4) 未在本文件登记的菜单项不会消失——MainLayout 兜底归入 900 域「未归类」;
// 5) 回退开关:localStorage.cma_menu_mode = 'pdca' 或 URL ?menu=pdca 即还原原 PDCA 五组。
export interface DecisionScene {
/** L2 决策场景文案(菜单内的小标题) */
label: string
/** 指向已有页面的 path(与 MENU_ITEMS 对齐) */
paths: string[]
}
export interface DecisionDomain {
/** 域编号:100 战略 / 200 预算 / … / 900 系统 */
id: string
/** L1 一级菜单文案 */
label: string
/** 域图标(取自 MainLayout iconMap 的键名) */
icon: string
scenes: DecisionScene[]
}
/** 9 个决策域;场景只用已有页面做聚合,缺能力域(500/700)待第 3 步补页 */
export const DECISION_DOMAINS: DecisionDomain[] = [
{
id: '100',
label: '战略管理',
icon: 'DataBoard',
scenes: [
{ label: '战略地图与执行', paths: ['/maps', '/strategy-map', '/alignment'] },
{ label: '目标与复盘', paths: ['/maps-review', '/okr-templates'] },
{ label: '增长与产品组合', paths: ['/growth-quality', '/product-matrix'] },
{ label: '经营总览', paths: ['/dashboard'] },
],
},
{
id: '200',
label: '预算管理',
icon: 'Money',
scenes: [
{ label: '保本与预测', paths: ['/budget', '/predict'] },
{ label: '预算差异', paths: ['/deviations'] },
],
},
{
id: '300',
label: '成本管理',
icon: 'Coin',
scenes: [
{ label: '成本结构与降本', paths: ['/cost', '/cost-intelligence'] },
{ label: '费用与成本控制', paths: ['/expenses', '/mpm-calculator'] },
],
},
{
id: '400',
label: '营运管理',
icon: 'Connection',
scenes: [
{ label: '回款与资金计划', paths: ['/cash-plan', '/receivables'] },
{ label: '客户与资金总览', paths: ['/customer', '/treasury-dashboard'] },
{ label: '改善行动', paths: ['/action-plans'] },
],
},
{
id: '500',
label: '投融资管理',
icon: 'TrendCharts',
scenes: [
{ label: '投资决策', paths: ['/real-options'] },
{ label: '资金内控', paths: ['/treasury-payment'] },
],
},
{
id: '600',
label: '绩效管理',
icon: 'DataLine',
scenes: [
{ label: 'KPI 执行', paths: ['/kpis', '/bot-kpis'] },
{ label: '学习与成长', paths: ['/learning-dashboard'] },
],
},
{
id: '700',
label: '风险管理',
icon: 'WarningFilled',
scenes: [
{ label: '预警与置信度', paths: ['/alerts', '/analysis-confidence'] },
{ label: '税务合规', paths: ['/tax-compliance'] },
],
},
{
id: '800',
label: '管理报告',
icon: 'Document',
scenes: [
{ label: '经营成果', paths: ['/reports', '/dupont-analysis'] },
{ label: '现金与结构', paths: ['/cashflow-structure'] },
],
},
{
id: '900',
label: '信息系统',
icon: 'Setting',
scenes: [
{ label: '数据治理', paths: ['/data', '/data-quality', '/data-classification'] },
{ label: '组织与权限', paths: ['/org', '/users', '/permissions'] },
{ label: '平台配置', paths: ['/notifications', '/knowledge', '/ai-suggestions'] },
],
},
]
/** 常驻入口(不占域,置顶显示) */
export const PINNED_PATHS: string[] = ['/my-dashboard']
/** 域菜单默认展开的域(只展开战略域;系统域默认折叠) */
export const DOMAIN_DEFAULT_OPENED: string[] = ['domain-100']
+92 -10
View File
@@ -5,19 +5,42 @@
<div class="logo">
<span v-show="!isCollapse">{{ logoText }}</span>
</div>
<el-menu :default-active="activeMenu" :collapse="isCollapse" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF" @select="onMenuSelect">
<template v-for="group in menuGroups" :key="group.label">
<el-sub-menu :index="'group-' + group.label" v-if="group.items.length > 0">
<el-menu :default-active="activeMenu" :collapse="isCollapse" :default-openeds="defaultOpeneds" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF" @select="onMenuSelect">
<!-- ── 模式一(默认):决策域菜单 L1 决策域 → L2 决策场景 → 页面 ── -->
<template v-if="menuMode === 'domain'">
<!-- 常驻入口(不占域) -->
<el-menu-item v-for="item in pinnedItems" :key="item.path" :index="item.path">
<el-icon><component :is="iconMap[item.icon]" /></el-icon>
<template #title><span>{{ item.label }}</span></template>
</el-menu-item>
<el-sub-menu v-for="domain in domainGroups" :key="'domain-' + domain.id" :index="'domain-' + domain.id">
<template #title>
<el-icon><component :is="groupIcon(group.label)" /></el-icon>
<span>{{ group.label }}</span>
<el-icon><component :is="domainIcon(domain.icon)" /></el-icon>
<span>{{ domain.label }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<el-icon><component :is="iconMap[item.icon]" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
<el-menu-item-group v-for="scene in domain.scenes" :key="domain.id + '-' + scene.label" :title="scene.label">
<el-menu-item v-for="item in scene.items" :key="item.path" :index="item.path">
<el-icon><component :is="iconMap[item.icon]" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu-item-group>
</el-sub-menu>
</template>
<!-- ── 模式二(回退开关):原 PDCA 五组分组 ── -->
<template v-else>
<template v-for="group in menuGroups" :key="group.label">
<el-sub-menu :index="'group-' + group.label" v-if="group.items.length > 0">
<template #title>
<el-icon><component :is="groupIcon(group.label)" /></el-icon>
<span>{{ group.label }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<el-icon><component :is="iconMap[item.icon]" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-sub-menu>
</template>
</template>
</el-menu>
</el-aside>
<el-container>
@@ -34,7 +57,10 @@
<el-dropdown trigger="click">
<span class="user-info"><el-icon><User /></el-icon>{{ userName }}</span>
<template #dropdown>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
<el-dropdown-item disabled>菜单模式:{{ menuModeLabel }}</el-dropdown-item>
<el-dropdown-item v-if="menuMode === 'domain'" @click="switchMenuMode('pdca')">切回 PDCA 五组(回退)</el-dropdown-item>
<el-dropdown-item v-else @click="switchMenuMode('domain')">切换为决策域菜单</el-dropdown-item>
<el-dropdown-item divided @click="logout">退出登录</el-dropdown-item>
</template>
</el-dropdown>
</div>
@@ -53,12 +79,32 @@ import {
DataBoard, Tools, Collection, Edit, Reading, Opportunity, PieChart, Lock,
} from '@element-plus/icons-vue'
import { MENU_ITEMS, ROLE_LABELS } from '../permission'
import { DECISION_DOMAINS, PINNED_PATHS, DOMAIN_DEFAULT_OPENED } from '../config/decisionDomains'
const route = useRoute()
const router = useRouter()
const isCollapse = ref(false)
const mobileMenuOpen = ref(false)
// ── 菜单模式(2026-10-18 决策域重构):domain = 决策域菜单(默认) / pdca = 原 PDCA 五组
// 回退开关:右上角用户名下拉可随时切;URL ?menu=pdca 或 localStorage.cma_menu_mode='pdca' 同样生效
const MENU_MODE_KEY = 'cma_menu_mode'
function initialMenuMode(): 'domain' | 'pdca' {
const q = new URLSearchParams(window.location.search).get('menu')
if (q === 'pdca' || q === 'domain') {
localStorage.setItem(MENU_MODE_KEY, q)
return q
}
return localStorage.getItem(MENU_MODE_KEY) === 'pdca' ? 'pdca' : 'domain'
}
const menuMode = ref<'domain' | 'pdca'>(initialMenuMode())
const menuModeLabel = computed(() => (menuMode.value === 'domain' ? '决策域' : 'PDCA 五组'))
function switchMenuMode(mode: 'domain' | 'pdca') {
menuMode.value = mode
localStorage.setItem(MENU_MODE_KEY, mode)
}
// 侧边栏品牌:带当前登录企业名(如「陕西酣客 · 管理会计OS」),账套由登录窗口选定
const logoText = computed(() => {
if (currentEntityName.value) return `${currentEntityName.value} · 管理会计OS`
@@ -126,6 +172,39 @@ const menuGroups = computed(() => {
return groups
})
// ── 决策域菜单:把角色过滤后的菜单项挂到 L1 域 / L2 场景上(文案与 roles 仍取 MENU_ITEMS)
type MenuItemT = (typeof MENU_ITEMS)[number]
const pinnedItems = computed(() => filteredMenus.value.filter(item => PINNED_PATHS.includes(item.path)))
const domainGroups = computed(() => {
const byPath = new Map<string, MenuItemT>(filteredMenus.value.map(item => [item.path, item]))
const used = new Set<string>(PINNED_PATHS)
const groups = DECISION_DOMAINS.map(domain => ({
id: domain.id,
label: domain.label,
icon: domain.icon,
scenes: domain.scenes
.map(scene => {
const items = scene.paths.map(p => byPath.get(p)).filter((x): x is MenuItemT => !!x)
items.forEach(item => used.add(item.path))
return { label: scene.label, items }
})
.filter(scene => scene.items.length > 0),
})).filter(domain => domain.scenes.length > 0)
// 兜底:未登记的菜单项不得消失,统一收进 900「未归类」
const rest = filteredMenus.value.filter(item => !used.has(item.path))
if (rest.length > 0) {
const system = groups.find(domain => domain.id === '900')
if (system) system.scenes.push({ label: '未归类', items: rest })
else groups.push({ id: '900', label: '信息系统', icon: 'Setting', scenes: [{ label: '未归类', items: rest }] })
}
return groups
})
const domainIcon = (name: string) => iconMap[name] || Collection
const defaultOpeneds = computed(() => (menuMode.value === 'domain' ? DOMAIN_DEFAULT_OPENED : []))
const activeMenu = computed(() => route.path)
const pageTitle = computed(() => (route.meta?.title as string) || '管理会计OS')
@@ -159,6 +238,9 @@ function onMenuSelect() {
/* 折叠菜单样式覆盖:子菜单项缩进 */
.el-menu :deep(.el-sub-menu .el-menu-item) { padding-left: 54px !important; }
/* 决策域菜单:L2 决策场景小标题(轻量、缩进一级) */
.el-menu :deep(.el-menu-item-group__title) { padding-left: 44px !important; font-size: 12px; line-height: 22px; color: #8fa3b8; }
/* 移动端 */
@media screen and (max-width: 768px) {
.hide-mobile { display: none !important; }