Files
mes-ui-d2/docs/menu-permission-migration.md
sheng 2cc8329695 refactor: remove old demo pages and static menu logic
1. 删除大量旧的示例页面、组件示例和静态菜单配置
2. 新增菜单扁平数组转树形结构工具函数
3. 重构菜单加载逻辑,改为从后端动态获取并格式化
4. 新增全局权限检查方法和自定义权限指令
5. 优化侧边栏菜单路由跳转逻辑,自动跳转第一个有权限的子页面
6. 移除路由中对旧demo模块的引用
2026-05-27 18:07:48 +08:00

19 KiB
Raw Blame History

Menu 权限迁移方案

参照项目:D:\code\company\SCTMES_MES_V5\vue-app(以下简称"源项目"


一、源项目SCTMESMenu 权限架构

1.1 后端接口

GET system_settings/menu_configuration/menu/all

API 文件:src/api/system_settings/menu_configuration/menu.js

返回当前用户可见的扁平数组,字段如下:

字段 类型 说明
menu_id number 主键,用于构建父子关系
parent_id number 父节点 ID0 表示顶栏根节点
url string 前端路由路径,如 /system_settings/user_management/user
name string 菜单显示名称
icon string 图标名FontAwesome
is_navi number 是否作为导航菜单展示1=是)
status number 启用/禁用
type string 菜单类型
params string 额外参数
sort number 排序
remark string 备注

1.2 菜单数据全链路

登录成功 → account.login() → dispatch('load')
                                    │
                                    ▼
                              account.load()
                                    │
                          ...加载 user/theme/transition/page/menu/size/color...
                                    │
                                    ▼
                         dispatch('d2admin/menu/sourceDataLoad')
                                    │
                       ┌────────────┴────────────┐
                       │                         │
                  localStorage             localStorage 为空
                   有缓存                    且有 token
                       │                         │
                       ▼                         ▼
                 直接读缓存              GET /menu/all 后端接口
                                            │
                                            ▼
                                      写入 localStorage 缓存
                                            │
                       └────────────┬────────────┘
                                    │
                                    ▼
                            menu.install(this, sourceData)

Store 文件src/store/modules/d2admin/modules/menu.js

state: {
    header: [],
    aside: [],
    asideCollapse: setting.menu.asideCollapse,
    asideTransition: setting.menu.asideTransition,
    authKey: {},       // ← 权限字典 { '/path': '菜单名', ... }
    sourceData: []     // ← 菜单源数据(来自后端或本地缓存)
}

1.3 sourceDataLoad action 核心逻辑

async sourceDataLoad({ state, dispatch }) {
    // 1. 优先从 localStorage 读取缓存
    state.sourceData = await dispatch('d2admin/db/get', {
        dbName: 'database',
        path: '$menu.sourceData',
        defaultValue: [],
        user: true
    }, { root: true })

    // 2. 缓存为空且已登录 → 调后端获取
    if (!state.sourceData.length && util.cookies.get('token')) {
        const res = await getMenuAll(null)
        state.sourceData = res.data || []
        await dispatch('d2admin/db/set', {
            dbName: 'database',
            path: '$menu.sourceData',
            value: state.sourceData,
            user: true
        }, { root: true })
    }

    // 3. 处理为 header/aside/authKey
    menu.install(this, state.sourceData)
}

1.4 menu.install() — 扁平数据 → 菜单树 + 权限字典

文件src/menu/index.js

export default {
    install(vm, source) {
        // 1. 构建权限字典 { '/path': '菜单名' }
        vm.commit('d2admin/menu/headerAuth', source)

        // 2. 扁平数据转为 {header: [], aside: []} 树结构
        const { header, aside } = getMenuData(source)

        // 3. 写入 store → 触发菜单渲染
        vm.commit('d2admin/menu/headerSet', header)
        vm.commit('d2admin/menu/asideSet', aside)
    }
}

getMenuData() 核心流程:

source (扁平数组)
    │
    ├── 过滤: is_navi !== 1 的跳过
    │
    ├── parent_id === 0 → 推入 header[]
    │
    └── 全部节点 → 推入 aside[]
            │
            ▼
       util.formatDataToTree(aside) { menu_id ↔ parent_id }
            │
            ▼
        树形结构 aside

headerAuth mutation:遍历 source,将每个有 url 的节点写入 authKey[url] = name

headerAuth(state, source) {
    let auth = {}
    source.forEach(value => {
        if (value.url) {
            auth[value.url] = value.name
        }
    })
    state.authKey = auth
}

1.5 权限控制系统

$permission() 方法 — 定义在 src/plugin/d2admin/index.js

Vue.prototype.$permission = (value, type = 'menu') => {
    let path = ''
    const auth = store.state.d2admin.menu.authKey

    switch (type) {
        case 'menu':                    // 直接用 URL 查
            path = value
            break
        case 'router':                  // 路由 name → URL
            path = value.name.replace(/-/g, '/')
            path.slice(0, 1) !== '/' && (path = '/' + path)
            break
    }

    return !!(path && Object.prototype.hasOwnProperty.call(auth, path))
}

v-permission 指令 — 定义在 src/main.js

Vue.directive('permission', {
    bind: (el, binding) => {
        if (!Vue.prototype.$permission(binding.value)) {
            el.parentNode ? el.parentNode.removeChild(el) : el.style.display = 'none'
        }
    }
})

使用示例:

<el-button v-permission="'/system_settings/user_management/user'">新增用户</el-button>

1.6 菜单导航权限过滤

Menu Mixinsrc/layout/header-aside/components/mixin/menu.js

点击顶栏一级菜单时,不直接跳转,而是查找第一个有权限的子路由:

getRouterAuthPath(index, indexPath) {
    // 子路由直接访问
    if (index === '/index' || !indexPath || indexPath.length > 1) {
        this.$router.push({ path: index })
        return
    }

    // 查找一级路由下第一个有权限的子路由
    let router = null
    for (const value of frameInRoutes) {
        if (value.path === index) {
            router = value.children
            break
        }
    }

    if (!router) {
        this.$router.push({ path: index })
        return
    }

    for (const value of router) {
        const newPath = index + '/' + value.path
        if (this.$permission(newPath)) {
            this.$router.push({ path: newPath })
            break
        }
    }
}

1.7 路由 — 全部静态注册

路由在 src/router/routes.js全部静态定义,不使用 addRoute

路由守卫(src/router/index.js)仅判断 token 是否存在,不校验具体路由权限。权限控制在菜单渲染菜单点击时完成。

1.8 main.js 启动流程

src/main.js

created() {
    // 仅初始化页面池,不再设置静态菜单
    this.$store.commit('d2admin/page/init', frameInRoutes)
    // 静态菜单设置已注释:
    // this.$store.commit('d2admin/menu/headerSet', menuHeader)
    // this.$store.commit('d2admin/search/init', menuHeader)
},
mounted() {
    this.$store.dispatch('d2admin/account/load')  // → sourceDataLoad → 获取菜单
},
watch: {
    // $route.matched 切换侧边栏的 watch 已注释
}

二、mes-ui 当前现状 vs 目标

模块 mes-ui 现状 源项目(目标)
菜单数据来源 src/menu/modules/*.js 静态硬编码 后端 GET /menu/all + localStorage 缓存
sourceDataLoad 仅 localStorage 读取,不参与菜单构建 localStorage 兜底 + 调后端 → menu.install()
权限字典 authKey: { '/path': 'name' }
菜单树构建 编译时 supplementPath() 运行时 getMenuData() + formatDataToTree()
权限检查 $permission() + v-permission 指令
菜单点击 直接 $router.push getRouterAuthPath() 查找首个有权限子路由
main.js 菜单初始化 createdheaderSet(menuHeader) 注释掉静态菜单,由 mounted 中的 load() 触发
路由注册 全部静态 全部静态(一致)
路由守卫 仅 token 检查 仅 token 检查(一致)

三、迁移计划

阶段 1:约定后端接口格式

后端接口:GET /api/menu/all(或复用源项目的 system_settings/menu_configuration/menu/all

返回格式(扁平数组):

{
  "code": 0,
  "data": [
    { "menu_id": 1, "parent_id": 0, "url": "/index", "name": "首页", "icon": "home", "is_navi": 1 },
    { "menu_id": 2, "parent_id": 0, "url": "/production_master_data", "name": "生产主数据", "icon": "cogs", "is_navi": 1 },
    { "menu_id": 3, "parent_id": 2, "url": "/production_master_data/factory_model/factory_area", "name": "工厂区域", "icon": "map-marker", "is_navi": 1 }
  ]
}

阶段 2:新增/改造 API 层

新增 src/api/menu.js(参照源项目 menu.js

import { request } from '@/api/_service'

export function getMenuAll(data) {
    return request({
        url: 'menu/all',
        method: 'get',
        params: {
            method: 'system_settings_menu_configuration_menu_all',
            platform: 'background',
            ...data
        }
    })
}

阶段 3:改造 menu.js Store

改造 src/store/modules/d2admin/modules/menu.js

改动 内容
state 新增 authKey: {}
sourceDataLoad action 改为localStorage 兜底 → 调后端 → menu.install()
mutations 新增 headerAuth(state, source) → 构建 authKey 字典
// 关键变更 — sourceDataLoad:
async sourceDataLoad({ state, commit, dispatch }) {
    // 1. 先读 localStorage 缓存
    state.sourceData = await dispatch('d2admin/db/get', {
        dbName: 'database',
        path: '$menu.sourceData',
        defaultValue: [],
        user: true
    }, { root: true })

    // 2. 缓存为空且已登录 → 调后端
    if (!state.sourceData.length && util.cookies.get('token')) {
        const res = await getMenuAll()
        state.sourceData = res || []
        await dispatch('d2admin/db/set', {
            dbName: 'database',
            path: '$menu.sourceData',
            value: state.sourceData,
            user: true
        }, { root: true })
    }

    // 3. 构建菜单树 + 权限字典
    menuUtil.install(this, state.sourceData)
}

阶段 4:改造 src/menu/index.js — 从静态导出变为工具模块

src/menu/index.js 从"导出静态菜单配置"改为"菜单处理工具"(参照源项目):

import util from '@/libs/util'

function getMenuData(arr) {
    let tree = { header: [], aside: [] }

    arr.forEach(value => {
        if (!value.is_navi) return         // 过滤非导航菜单

        const menuItem = {
            path: value.url,
            title: value.name,
            icon: value.icon,
            type: value.type,
            params: value.params
        }

        if (value.parent_id === 0) {
            tree.header.push({ ...menuItem })
        }

        menuItem.menu_id = value.menu_id
        menuItem.parent_id = value.parent_id
        tree.aside.push(menuItem)
    })

    // 扁平数组转树
    tree.aside = util.formatDataToTree(tree.aside)
    return tree
}

export default {
    install(vm, source) {
        vm.commit('d2admin/menu/headerAuth', source)
        const { header, aside } = getMenuData(source)
        vm.commit('d2admin/menu/headerSet', header)
        vm.commit('d2admin/menu/asideSet', aside)
    }
}

原有的静态菜单模块(src/menu/modules/demo-*.js)可保留但不再被引用,后续按需清理。

阶段 5:增加权限控制基础设施

5.1 $permission 方法

改造 src/plugin/d2admin/index.js,新增:

Vue.prototype.$permission = (value, type = 'menu') => {
    let path = ''
    const auth = store.state.d2admin.menu.authKey

    switch (type) {
        case 'menu':
            path = value
            break
        case 'router':
            path = value.name.replace(/-/g, '/')
            path.slice(0, 1) !== '/' && (path = '/' + path)
            break
    }

    return !!(path && Object.prototype.hasOwnProperty.call(auth, path))
}

5.2 v-permission 指令

改造 src/main.js,新增:

Vue.directive('permission', {
    bind: (el, binding) => {
        if (!Vue.prototype.$permission(binding.value)) {
            el.parentNode ? el.parentNode.removeChild(el) : el.style.display = 'none'
        }
    }
})

5.3 菜单点击权限过滤

改造 src/layout/header-aside/components/mixin/menu.js,增加 getRouterAuthPath 方法:

import { frameInRoutes } from '@/router/routes'

methods: {
    handleMenuSelect(index, indexPath) {
        if (/^d2-menu-empty-\d+$/.test(index) || index === undefined) {
            this.$message.warning('临时菜单')
        } else if (/^https:\/\/|http:\/\//.test(index)) {
            util.open(index)
        } else {
            this.getRouterAuthPath(index, indexPath)
        }
    },

    getRouterAuthPath(index, indexPath) {
        if (index === '/index' || !indexPath || indexPath.length > 1) {
            this.$router.push({ path: index })
            return
        }

        let router = null
        for (const value of frameInRoutes) {
            if (value.path === index) {
                router = value.children
                break
            }
        }

        if (!router) {
            this.$router.push({ path: index })
            return
        }

        for (const value of router) {
            const newPath = index + '/' + value.path
            if (this.$permission(newPath)) {
                this.$router.push({ path: newPath })
                break
            }
        }
    }
}

阶段 6:改造 main.js 启动流程

改造 src/main.js

created() {
    this.$store.commit('d2admin/page/init', frameInRoutes)
    // 注释掉静态菜单设置:
    // this.$store.commit('d2admin/menu/headerSet', menuHeader)
    // this.$store.commit('d2admin/search/init', menuHeader)
},
mounted() {
    this.$store.commit('d2admin/releases/versionShow')
    this.$store.dispatch('d2admin/account/load')   // → sourceDataLoad → 获取后端菜单
    this.$store.commit('d2admin/ua/get')
    this.$store.dispatch('d2admin/fullscreen/listen')
},
watch: {
    // 注释掉 $route.matched 侧边栏切换(由 store.aside 直接驱动)
    // '$route.matched': { ... }
}

阶段 7:增加 util.formatDataToTree 工具函数

改造 src/libs/util.js,新增:

util.formatDataToTree = (data, key = 'menu_id', pid = 'parent_id') => {
    if (!data || data.length <= 0) return []

    let map = {}
    data.forEach(value => { map[value[key]] = { ...value } })

    let tree = []
    data.forEach(item => {
        const id = item[key]
        if (map[id][pid] && map[map[id][pid]]) {
            if (!map[map[id][pid]].children) {
                map[map[id][pid]].children = []
            }
            map[map[id][pid]].children.push(map[id])
        } else {
            tree.push(map[id])
        }
    })
    return tree
}

四、影响的文件清单

文件 改动类型 说明
src/api/menu.js 新增 菜单 APIgetMenuAll
src/store/modules/d2admin/modules/menu.js 改造 sourceDataLoad 加后端调用 + headerAuth mutation + authKey state
src/menu/index.js 重写 从静态配置改为 install() 工具模块
src/plugin/d2admin/index.js 改造 新增 $permission 方法
src/main.js 改造 注释静态菜单设置;新增 v-permission 指令
src/layout/header-aside/components/mixin/menu.js 改造 增加 getRouterAuthPath 权限过滤
src/libs/util.js 改造 新增 formatDataToTree 工具函数
src/menu/modules/demo-*.js 后续可删 静态菜单数据不再使用,单独清理

五、完整时序图

 用户登录
   │
   ▼
 login/index.vue → dispatch('d2admin/account/login')
   │
   ▼
 account.js login action
   ├── loginAdminUser()                  → 后端登录接口
   ├── util.cookies.set()                → 存 token/uuid
   ├── dispatch('d2admin/user/set')      → 存用户信息
   └── dispatch('load')
         │
         ├── d2admin/user/load           → 加载用户名
         ├── d2admin/theme/load          → 加载主题
         ├── d2admin/transition/load     → 加载过渡效果
         ├── d2admin/page/openedLoad     → 加载标签页
         ├── d2admin/menu/asideLoad      → 加载侧边栏收起设置
         ├── d2admin/size/load           → 加载全局尺寸
         ├── d2admin/color/load          → 加载颜色设置
         └── d2admin/menu/sourceDataLoad
               │
               ├── 先读 localStorage 缓存
               ├── 缓存空 → GET /api/menu/all 后端接口
               ├── 写入 localStorage 缓存
               └── menu.install(this, sourceData)
                     ├── headerAuth → 构建 authKey 权限字典
                     ├── getMenuData → 扁平 → {header, aside} 树
                     ├── headerSet → 顶栏菜单渲染
                     └── asideSet  → 侧边栏菜单渲染
   │
   ▼
 跳转到首页 → 菜单已按权限渲染

六、注意事项

  1. 路由仍是静态注册的mes-ui 不需要改造成动态 addRoute,路由全部在 router/routes.js 中静态定义即可,权限通过菜单显示/隐藏 + getRouterAuthPath 控制。
  2. 不要删除源项目代码:源项目在 D:\code\company\SCTMES_MES_V5\vue-app,仅作为参照,不对其做任何修改。
  3. localStorage 缓存是离线兜底:首次登录调后端,后续从缓存读取;注销时清空缓存。
  4. block cookie:已在登录页 mounted 中设为 'false',注销时设为 'true',用于控制错误日志是否弹出提示。