# Menu 权限迁移方案 > 参照项目:`D:\code\company\SCTMES_MES_V5\vue-app`(以下简称"源项目") --- ## 一、源项目(SCTMES)Menu 权限架构 ### 1.1 后端接口 ``` GET system_settings/menu_configuration/menu/all ``` API 文件:[src/api/system_settings/menu_configuration/menu.js](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\api\system_settings\menu_configuration\menu.js) 返回当前用户可见的**扁平数组**,字段如下: | 字段 | 类型 | 说明 | |------|------|------| | `menu_id` | number | 主键,用于构建父子关系 | | `parent_id` | number | 父节点 ID,`0` 表示顶栏根节点 | | `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](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\store\modules\d2admin\modules\menu.js) ```js state: { header: [], aside: [], asideCollapse: setting.menu.asideCollapse, asideTransition: setting.menu.asideTransition, authKey: {}, // ← 权限字典 { '/path': '菜单名', ... } sourceData: [] // ← 菜单源数据(来自后端或本地缓存) } ``` ### 1.3 `sourceDataLoad` action 核心逻辑 ```js 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](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\menu\index.js) ```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`: ```js 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](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\plugin\d2admin\index.js#L50): ```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](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\main.js#L72-L78): ```js Vue.directive('permission', { bind: (el, binding) => { if (!Vue.prototype.$permission(binding.value)) { el.parentNode ? el.parentNode.removeChild(el) : el.style.display = 'none' } } }) ``` 使用示例: ```html 新增用户 ``` ### 1.6 菜单导航权限过滤 **Menu Mixin** — [src/layout/header-aside/components/mixin/menu.js](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\layout\header-aside\components\mixin\menu.js): 点击顶栏一级菜单时,不直接跳转,而是查找第一个有权限的子路由: ```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](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\router\routes.js) 中**全部静态定义**,不使用 `addRoute`。 路由守卫([src/router/index.js](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\router\index.js))仅判断 token 是否存在,不校验具体路由权限。权限控制在**菜单渲染**和**菜单点击时**完成。 ### 1.8 main.js 启动流程 [src/main.js](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\main.js#L108-L137) ```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 菜单初始化 | `created` 中 `headerSet(menuHeader)` | 注释掉静态菜单,由 `mounted` 中的 `load()` 触发 | | 路由注册 | 全部静态 | 全部静态(一致) | | 路由守卫 | 仅 token 检查 | 仅 token 检查(一致) | --- ## 三、迁移计划 ### 阶段 1️⃣:约定后端接口格式 后端接口:`GET /api/menu/all`(或复用源项目的 `system_settings/menu_configuration/menu/all`) 返回格式(扁平数组): ```json { "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](file:///D:\code\company\SCTMES_MES_V5\vue-app\src\api\system_settings\menu_configuration\menu.js)): ```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](file:///d:\code\mes\mes-ui\src\store\modules\d2admin\modules\menu.js) | 改动 | 内容 | |------|------| | state | 新增 `authKey: {}` | | `sourceDataLoad` action | 改为:localStorage 兜底 → 调后端 → `menu.install()` | | mutations | 新增 `headerAuth(state, source)` → 构建 `authKey` 字典 | ```js // 关键变更 — 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](file:///d:\code\mes\mes-ui\src\menu\index.js) 从"导出静态菜单配置"改为"菜单处理工具"(参照源项目): ```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`,新增: ```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`,新增: ```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](file:///d:\code\mes\mes-ui\src\layout\header-aside\components\mixin\menu.js),增加 `getRouterAuthPath` 方法: ```js 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](file:///d:\code\mes\mes-ui\src\main.js): ```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`,新增: ```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` | **新增** | 菜单 API(`getMenuAll`) | | `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'`,用于控制错误日志是否弹出提示。