# 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
<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'`，用于控制错误日志是否弹出提示。
