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

602 lines
19 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](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
<el-button v-permission="'/system_settings/user_management/user'">新增用户</el-button>
```
### 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'`,用于控制错误日志是否弹出提示。