refactor(menu): 侧边栏按决策域重构(L1 九域 + L2 决策场景)+ PDCA 回退开关
ci/woodpecker/push/main Pipeline was successful
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:
Vendored
+1
@@ -48,6 +48,7 @@ declare module 'vue' {
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
|
||||
@@ -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']
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user