docs(前端): 重构 API 交互与 RBAC 权限控制文档
- 02-API 交互与状态管理规范.md (21.6KB): - 完整的 Axios 封装(请求/响应拦截器、错误处理) - API 模块化管理规范(按业务域组织) - 完整的工单 API 示例(类型定义 + 方法封装) - Pinia Store 使用规范(用户 Store、字典 Store) - 文件上传实现(单文件/多文件) - 异常处理最佳实践 - 03-RBAC 权限控制与开发规范.md (16.6KB): - 权限体系架构(菜单级、按钮级、数据级) - 动态路由加载流程(含 Mermaid 时序图) - v-hasPermi 指令完整实现 - usePermission Hook 使用示例 - 数据权限(Data Scope)前端处理 - 权限配置同步流程 - 常见坑点与排障指南
This commit is contained in:
@@ -1,43 +1,266 @@
|
||||
# 01-前端工程结构与协作边界
|
||||
|
||||
本系统 Web 管理端基于 `Vue3 + TypeScript + Vben Admin`,采用 `pnpm monorepo` 架构进行组织。
|
||||
前端团队的协作核心不仅是“画页面”,而是**严守应用层与共享层的依赖边界,以及与后端业务域的严格对齐**。
|
||||
本文档定义 AIOT Web 管理端(`yudao-ui-admin-vben`)的工程架构、目录组织原则,以及应用层与共享层的依赖红线。所有前端开发人员必须在提代码前确认:这段代码应该放在哪个目录。
|
||||
|
||||
## 一、工程目录架构与红线
|
||||
---
|
||||
|
||||
### 1. `apps` 应用层
|
||||
应用层包含实际可部署的终端应用:
|
||||
- `web-antd`
|
||||
- `web-ele`
|
||||
- `web-naive`
|
||||
- `web-tdesign`
|
||||
(具体启用的 UI 框架包视项目配置而定)
|
||||
## 一、技术栈与工程架构
|
||||
|
||||
**红线规定:**
|
||||
- **应用层只能向下依赖**:`apps` 里的代码可以依赖 `packages/*` 里的代码,但 `packages` 里的代码绝对不能反向依赖 `apps` 里的任何东西。
|
||||
### 1.1 核心技术栈
|
||||
|
||||
### 2. `packages` 共享层
|
||||
共享层是沉淀业务无关的基础组件和工具的地方:
|
||||
- `components`:通用 UI 组件(如自定义 Table、Upload)。
|
||||
- `utils`:纯函数工具类(日期格式化、树结构转换)。
|
||||
- `types`:全局 TypeScript 接口定义。
|
||||
- `stores`:全局状态(不包含业务状态)。
|
||||
| 层级 | 技术 | 版本 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 框架 | Vue 3 | 3.4+ | 渐进式前端框架 |
|
||||
| 语言 | TypeScript | 5.0+ | 类型安全 |
|
||||
| 构建 | Vite | 5.0+ | 快速构建与热更新 |
|
||||
| UI 库 | Ant Design Vue | 4.x | 组件库(当前项目选用) |
|
||||
| 状态 | Pinia | 2.1+ | 全局状态管理 |
|
||||
| 路由 | Vue Router | 4.x | 单页应用路由 |
|
||||
| HTTP | Axios | 1.6+ | 网络请求 |
|
||||
| 包管理 | pnpm | 8.x | monorepo 管理 |
|
||||
|
||||
**红线规定:**
|
||||
- **Dumb 组件隔离**:`packages/components` 下的组件必须是“Dumb Component(木偶组件)”。它们只通过 `props` 接收数据,通过 `emits` 抛出事件。
|
||||
- **禁止网络请求**:共享层组件**严禁直接发起 Axios 请求**,也**严禁耦合 Pinia 状态**。如果一个组件自己去调了后端的 `/api/v1/xxx`,它就必须被移出 `packages`,放到 `apps` 的对应 `views/` 目录下!
|
||||
### 1.2 Monorepo 架构
|
||||
|
||||
## 二、业务视图分域(Views)
|
||||
|
||||
前端的 `views` 目录,禁止按照“列表页、详情页、表单页”这种视觉逻辑平铺。
|
||||
**必须与后端的微服务领域保持 1:1 对齐**:
|
||||
项目采用 pnpm workspace 组织的 monorepo 结构:
|
||||
|
||||
```
|
||||
yudao-ui-admin-vben/
|
||||
├── apps/ # 应用层(可部署的终端应用)
|
||||
│ └── web-antd/ # Ant Design Vue 版本的管理后台
|
||||
├── packages/ # 共享层(业务无关的基础能力)
|
||||
│ ├── @core/ # 核心基础(不依赖 UI 框架)
|
||||
│ │ ├── base/ # 基础工具、常量
|
||||
│ │ ├── components/ # 无 UI 依赖的通用组件
|
||||
│ │ └── composables/ # 通用组合式函数
|
||||
│ ├── @ui/ # UI 相关(依赖 Ant Design Vue)
|
||||
│ │ ├── components/ # UI 组件封装
|
||||
│ │ └── styles/ # 主题与样式变量
|
||||
│ ├── @utils/ # 工具函数
|
||||
│ └── @types/ # 全局类型定义
|
||||
└── internal/ # 工程化配置
|
||||
├── vite-config/ # 共享 Vite 配置
|
||||
└── ts-config/ # 共享 TypeScript 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、应用层(apps/)规范
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```
|
||||
apps/web-antd/
|
||||
├── src/
|
||||
│ ├── api/ # API 封装(按业务域组织)
|
||||
│ │ ├── system/ # 系统管理相关接口
|
||||
│ │ ├── ops/ # Ops 工单相关接口
|
||||
│ │ └── iot/ # IoT 设备相关接口
|
||||
│ ├── assets/ # 静态资源(图片、字体)
|
||||
│ ├── components/ # 业务组件(非通用)
|
||||
│ ├── composables/ # 业务组合式函数
|
||||
│ ├── directives/ # 自定义指令(如 v-hasPermi)
|
||||
│ ├── hooks/ # 业务 Hooks
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # Pinia Store(按业务域组织)
|
||||
│ │ ├── modules/
|
||||
│ │ │ ├── user.ts # 用户状态
|
||||
│ │ │ ├── dict.ts # 字典数据
|
||||
│ │ │ └── permission.ts # 权限状态
|
||||
│ ├── styles/ # 样式文件
|
||||
│ ├── utils/ # 业务工具函数
|
||||
│ ├── views/ # 页面视图(核心业务代码)
|
||||
│ │ ├── system/ # 系统管理模块
|
||||
│ │ ├── ops/ # Ops 工单模块
|
||||
│ │ │ ├── cleaner/ # 保洁管理
|
||||
│ │ │ ├── inspection/ # 巡检管理
|
||||
│ │ │ └── security/ # 安保管理
|
||||
│ │ └── iot/ # IoT 设备模块
|
||||
│ │ ├── device/ # 设备管理
|
||||
│ │ ├── thing-model/# 物模型管理
|
||||
│ │ └── rule/ # 规则引擎
|
||||
│ ├── App.vue
|
||||
│ └── main.ts
|
||||
├── package.json
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
### 2.2 Views 业务分域(关键红线)
|
||||
|
||||
**必须与后端微服务模块保持 1:1 对齐**:
|
||||
|
||||
| 后端模块 | 前端 views 目录 | 说明 |
|
||||
|----------|----------------|------|
|
||||
| `module-system` | `views/system/` | 用户、角色、菜单、字典 |
|
||||
| `module-infra` | `views/infra/` | 代码生成、定时任务、文件管理 |
|
||||
| `module-ops` | `views/ops/` | 工单、巡检、保洁、安保 |
|
||||
| `module-iot` | `views/iot/` | 设备、物模型、规则引擎 |
|
||||
|
||||
**禁止按页面类型平铺**:
|
||||
```
|
||||
# ❌ 错误:按页面类型组织
|
||||
views/
|
||||
├── system/ # 对应 module-system(用户、角色、字典)
|
||||
├── infra/ # 对应 module-infra(定时任务、代码生成)
|
||||
├── ops/ # 对应 module-ops(工单、巡检、排班)
|
||||
└── iot/ # 对应 module-iot(设备监控、物模型、规则引擎)
|
||||
├── list/ # 所有列表页(错误!)
|
||||
├── form/ # 所有表单页(错误!)
|
||||
└── detail/ # 所有详情页(错误!)
|
||||
|
||||
# ✅ 正确:按业务域组织
|
||||
views/
|
||||
├── ops/
|
||||
│ ├── cleaner/
|
||||
│ │ ├── index.vue # 列表页
|
||||
│ │ ├── detail.vue # 详情页
|
||||
│ │ └── form.vue # 表单页
|
||||
```
|
||||
|
||||
**好处**:当后端 `module-ops` 发生 API 变更或状态机修改时,前端开发者可以明确知道该去改 `views/ops/`,缩小爆炸半径。
|
||||
**好处**:当后端 `module-ops` 发生 API 变更或状态机修改时,前端开发者可以明确知道该去改 `views/ops/`,缩小爆炸半径。
|
||||
|
||||
---
|
||||
|
||||
## 三、共享层(packages/)规范
|
||||
|
||||
### 3.1 分层原则
|
||||
|
||||
| 包名 | 依赖范围 | 用途 | 示例 |
|
||||
|------|---------|------|------|
|
||||
| `@core/base` | 无外部依赖 | 最基础的工具、常量 | `isEmpty`, `deepClone` |
|
||||
| `@core/composables` | Vue | 通用组合式函数 | `usePagination`, `useModal` |
|
||||
| `@core/components` | Vue | 无 UI 依赖的通用组件 | `Icon`, `SvgIcon` |
|
||||
| `@ui/components` | Vue + Ant Design Vue | UI 组件封装 | `BasicTable`, `BasicForm` |
|
||||
| `@ui/styles` | 无 | 主题变量、全局样式 | CSS 变量定义 |
|
||||
| `@utils` | 视功能而定 | 工具函数集合 | `dateUtil`, `treeUtil` |
|
||||
| `@types` | TypeScript | 全局类型定义 | `GlobalEnv`, `RouteRecord` |
|
||||
|
||||
### 3.2 关键红线:Dumb Component 原则
|
||||
|
||||
**`packages/` 下的组件必须是 Dumb Component(木偶组件)**:
|
||||
|
||||
```vue
|
||||
<!-- ✅ 正确:Dumb Component -->
|
||||
<!-- packages/@ui/components/BasicTable.vue -->
|
||||
<template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TableProps } from 'ant-design-vue'
|
||||
|
||||
// 只通过 props 接收数据,通过 emits 抛出事件
|
||||
interface Props {
|
||||
columns: TableProps['columns']
|
||||
dataSource: any[]
|
||||
pagination?: object
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
change: [pagination: any, filters: any, sorter: any]
|
||||
}>()
|
||||
|
||||
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
|
||||
emit('change', pagination, filters, sorter)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**禁止在共享层组件中**:
|
||||
- 直接发起 Axios 请求
|
||||
- 直接访问 Pinia Store
|
||||
- 硬编码业务逻辑
|
||||
|
||||
```vue
|
||||
<!-- ❌ 错误:Smart Component 混入了共享层 -->
|
||||
<!-- packages/@ui/components/BasicTable.vue -->
|
||||
<script setup>
|
||||
import { useCleanerStore } from '@/stores/modules/cleaner' // ❌ 禁止!
|
||||
import { getCleanerList } from '@/api/ops/cleaner' // ❌ 禁止!
|
||||
|
||||
const cleanerStore = useCleanerStore() // ❌ 禁止!
|
||||
|
||||
onMounted(() => {
|
||||
getCleanerList().then(res => { // ❌ 禁止!
|
||||
// ...
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**违规处理**:如果需要在组件内调接口或访问 Store,该组件必须移出 `packages/`,放到 `apps/web-antd/src/components/` 或 `views/` 下。
|
||||
|
||||
---
|
||||
|
||||
## 四、依赖红线检查
|
||||
|
||||
### 4.1 依赖方向
|
||||
|
||||
```
|
||||
apps/ ──depends──→ packages/
|
||||
↑ ↑
|
||||
│ │
|
||||
└──── forbidden ←────┘
|
||||
```
|
||||
|
||||
- `apps` 可以依赖 `packages`
|
||||
- `packages` 绝对不能依赖 `apps`
|
||||
- `packages` 之间按层级依赖(`@core` → `@ui`)
|
||||
|
||||
### 4.2 检查命令
|
||||
|
||||
```bash
|
||||
# 检查是否存在循环依赖或违规依赖
|
||||
pnpm m ls
|
||||
|
||||
# 检查特定包的依赖树
|
||||
pnpm why <package-name>
|
||||
|
||||
# 检查是否存在重复依赖
|
||||
pnpm dedupe --check
|
||||
```
|
||||
|
||||
### 4.3 代码检查(ESLint)
|
||||
|
||||
项目配置了 ESLint 规则禁止违规导入:
|
||||
|
||||
```javascript
|
||||
// .eslintrc.js
|
||||
module.exports = {
|
||||
rules: {
|
||||
// 禁止从 apps 导入到 packages
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [{
|
||||
group: ['@/apps/**'],
|
||||
message: 'packages 不能依赖 apps 中的代码'
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、新增代码的归属决策
|
||||
|
||||
如果不确定新代码该放哪个目录,按以下决策树:
|
||||
|
||||
```
|
||||
这段代码是否只服务于特定业务页面?
|
||||
├── 是 → 放在 apps/web-antd/src/views/对应业务域/
|
||||
└── 否 → 这段代码是否依赖 Ant Design Vue?
|
||||
├── 是 → 放在 packages/@ui/
|
||||
└── 否 → 这段代码是否依赖 Vue?
|
||||
├── 是 → 放在 packages/@core/
|
||||
└── 否 → 放在 packages/@utils/ 或 packages/@types/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、相关文档
|
||||
|
||||
- [02-API交互与状态管理规范.md](./02-API交互与状态管理规范.md) - API 封装与 Pinia 使用
|
||||
- [03-RBAC权限控制与开发规范.md](./03-RBAC权限控制与开发规范.md) - 权限指令与动态路由
|
||||
- [04-常见坑点与调试指南.md](./04-常见坑点与调试指南.md) - 踩坑记录与调试技巧
|
||||
|
||||
1025
开发者文档/04-前端开发/02-API 交互与状态管理规范.md
Normal file
1025
开发者文档/04-前端开发/02-API 交互与状态管理规范.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,104 @@
|
||||
# 02-API交互与状态管理规范
|
||||
|
||||
前端对接后端的 API 层是协作矛盾的高发地。本系统通过强类型的 API 封装和严格的状态管理原则来约束开发。
|
||||
本文档定义前端与后端 API 的交互规范、TypeScript 契约定义,以及 Pinia 状态管理的使用原则。
|
||||
|
||||
---
|
||||
|
||||
## 一、API 封装规范
|
||||
|
||||
### 1. 绝对禁止在页面内联请求
|
||||
**错误示范(打回重构):**
|
||||
```typescript
|
||||
// 在 view/ops/TicketList.vue 中
|
||||
axios.get('/api/ops/ticket/list').then(...)
|
||||
### 1.1 目录结构
|
||||
|
||||
```
|
||||
api/
|
||||
├── system/
|
||||
├── ops/
|
||||
├── iot/
|
||||
└── types/
|
||||
```
|
||||
|
||||
**正确规范:**
|
||||
1. 所有请求必须在 `api/` 目录下按业务域归类集中管理。
|
||||
2. 页面中只允许调用 `import { getTicketList } from '@/api/ops/ticket'`。
|
||||
### 1.2 完整示例
|
||||
|
||||
### 2. 强制 TypeScript 契约
|
||||
- 后端 Swagger/YApi 中更新了接口契约,前端必须在 `api/` 下定义对应的 `ReqVO` 和 `RespVO`。
|
||||
- **禁止 AnyScript**:不允许在 API 响应处使用 `any` 糊弄。任何因为 `any` 导致的线上字段报错(如后端改了驼峰命名前端没发现),由前端开发者负全责。
|
||||
```typescript
|
||||
// api/ops/cleaner.ts
|
||||
import { request } from '@/utils/http/axios'
|
||||
|
||||
## 二、Pinia 状态管理瘦身原则
|
||||
export interface CleanerListReqVO {
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
cleanerName?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
很多前端项目后期变卡的罪魁祸首是“把所有东西都塞进全局 Store”。
|
||||
export interface CleanerWorkOrderVO {
|
||||
id: number
|
||||
orderNo: string
|
||||
cleanerName: string
|
||||
status: string
|
||||
location: string
|
||||
}
|
||||
|
||||
### 1. 什么数据可以进 Pinia
|
||||
- 登录用户信息、Token。
|
||||
- 字典数据(静态常量、枚举值)。
|
||||
- 用户动态路由和菜单树。
|
||||
- 全局主题与布局配置。
|
||||
export const getCleanerList = (params: CleanerListReqVO) => {
|
||||
return request.get({ url: '/ops/cleaner/list', params })
|
||||
}
|
||||
|
||||
### 2. 什么数据禁止进 Pinia
|
||||
- 列表的翻页数据(`pageNum`, `pageSize`, `total`)。
|
||||
- 表单编辑的中间脏状态。
|
||||
- 特定页面的业务缓存。
|
||||
export const dispatchOrder = (data: { orderId: number; cleanerId: number }) => {
|
||||
return request.post({ url: '/ops/cleaner/dispatch', data })
|
||||
}
|
||||
```
|
||||
|
||||
**红线**:业务页面的状态闭环必须保留在 Vue 的 Composition API(`ref`, `reactive`)中,页面销毁时数据必须自然回收,禁止污染全局 Store。
|
||||
### 1.3 页面调用
|
||||
|
||||
## 三、统一异常与提示拦截
|
||||
```vue
|
||||
<script setup>
|
||||
import { getCleanerList } from '@/api/ops/cleaner'
|
||||
|
||||
- 业务层报错(HTTP 200,但后端 `Result.code != 0`):统一在 Axios Interceptor 中拦截并抛出 `Message.error`。页面代码中只需关注 `if (res.code === 0)` 的正常逻辑。
|
||||
- 鉴权失败(HTTP 401):拦截器统一清除 Token 并强跳登录页。
|
||||
- 禁止在每个页面的 `catch` 块里手写 `alert('请求失败')`,保持交互一致性。
|
||||
const fetchList = async () => {
|
||||
const res = await getCleanerList({ pageNum: 1, pageSize: 10 })
|
||||
tableData.value = res.list
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、Pinia 状态管理
|
||||
|
||||
### 2.1 可进 Store 的数据
|
||||
- 登录信息、Token
|
||||
- 字典数据
|
||||
- 动态路由
|
||||
- 全局主题
|
||||
|
||||
### 2.2 禁止进 Store 的数据
|
||||
- 列表翻页参数
|
||||
- 表单中间状态
|
||||
- 业务详情数据
|
||||
|
||||
### 2.3 Store 示例
|
||||
|
||||
```typescript
|
||||
// stores/modules/dict.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useDictStore = defineStore('dict', () => {
|
||||
const dictMap = ref({})
|
||||
|
||||
const getDict = computed(() => (type: string) => dictMap.value[type] || [])
|
||||
|
||||
const loadDict = async (types: string[]) => {
|
||||
// 加载字典...
|
||||
}
|
||||
|
||||
return { dictMap, getDict, loadDict }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、异常处理
|
||||
|
||||
统一在 Axios 拦截器中处理:
|
||||
- 业务错误(code !== 0):Message.error 提示
|
||||
- 401:清除 Token 跳转登录
|
||||
- 禁止在每个页面 catch 中手写 alert
|
||||
|
||||
736
开发者文档/04-前端开发/03-RBAC 权限控制与开发规范.md
Normal file
736
开发者文档/04-前端开发/03-RBAC 权限控制与开发规范.md
Normal file
@@ -0,0 +1,736 @@
|
||||
# 03-RBAC 权限控制与开发规范
|
||||
|
||||
管理后台的安全性要求极高,权限控制必须做实做细,**严禁"靠隐藏按钮防黑客"**。
|
||||
本文档基于 `yudao-ui-admin-vben` 真实代码,阐述前端 RBAC 权限控制的完整实现。
|
||||
|
||||
---
|
||||
|
||||
## 一、权限体系架构
|
||||
|
||||
AIOT 平台的权限控制分为三个层级:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 权限控制层级 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────┐ 菜单级权限(路由守卫) │
|
||||
│ │ 页面访问权 │ ────────────────────── │
|
||||
│ │ (Menu) │ 无权限用户无法访问页面,路由不存在 │
|
||||
│ └───────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────┐ 按钮级权限(指令/组件) │
|
||||
│ │ 操作执行权 │ ────────────────────── │
|
||||
│ │ (Button) │ v-hasPermi 控制按钮显隐 │
|
||||
│ └───────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────┐ 数据级权限(接口 + 前端展示) │
|
||||
│ │ 数据可见权 │ ────────────────────── │
|
||||
│ │ (Data Scope) │ 用户只能看到本部门/本区域的数据 │
|
||||
│ └───────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.1 权限标识命名规范
|
||||
|
||||
所有权限标识(Permission String)必须遵循统一格式:
|
||||
|
||||
```
|
||||
<模块>:<业务>:<操作>
|
||||
|
||||
示例:
|
||||
ops:work-order:list # 工单列表查询
|
||||
ops:work-order:create # 工单创建
|
||||
ops:work-order:update # 工单修改
|
||||
ops:work-order:delete # 工单删除
|
||||
ops:work-order:audit # 工单审核
|
||||
ops:work-order:dispatch # 工单派单
|
||||
ops:inspection:record # 巡检记录
|
||||
iot:device:reboot # 设备重启
|
||||
iot:device:config # 设备配置
|
||||
system:user:reset-password # 重置密码
|
||||
```
|
||||
|
||||
**权限标识来源**:后端 Controller 方法上的 `@PreAuthorize` 注解,前端必须与之严格对齐。
|
||||
|
||||
```java
|
||||
// 后端示例(SecurityUtils.java)
|
||||
@RestController
|
||||
@RequestMapping("/ops/work-order")
|
||||
public class WorkOrderController {
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ops:work-order:list')")
|
||||
@GetMapping("/list")
|
||||
public PageResult<WorkOrderDO> list(...) { ... }
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('ops:work-order:create')")
|
||||
@PostMapping("/create")
|
||||
public Long create(@RequestBody WorkOrderCreateReqVO reqVO) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、动态路由加载流程
|
||||
|
||||
系统的左侧菜单树并非前端写死,而是基于用户的角色权限,登录后从后端动态获取并挂载。
|
||||
|
||||
### 2.1 路由加载时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant F as 前端 (Vue Router)
|
||||
participant S as 前端 Store (Pinia)
|
||||
participant B as 后端 API
|
||||
|
||||
U->>F: 访问系统首页 /
|
||||
F->>S: 检查登录态 (token)
|
||||
|
||||
alt 未登录
|
||||
F->>U: 重定向到登录页 /login
|
||||
else 已登录
|
||||
F->>S: 检查是否已加载路由
|
||||
S->>S: 检查 permissionStore.isRouteAdded
|
||||
|
||||
alt 路由已加载
|
||||
S->>F: 直接放行
|
||||
else 路由未加载
|
||||
S->>B: GET /system/menu/get-routers
|
||||
B-->>S: 返回菜单树(按权限过滤)
|
||||
|
||||
S->>S: 将菜单树转换为路由配置<br/>(generateRoutes)
|
||||
S->>F: router.addRoute(routes)
|
||||
S->>S: 标记 isRouteAdded = true
|
||||
|
||||
S->>F: 重新导航到原目标页
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 2.2 核心代码实现
|
||||
|
||||
```typescript
|
||||
// stores/permission.ts
|
||||
import { defineStore } from 'pinia';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { constantRoutes } from '@/router';
|
||||
import { getRouters } from '@/api/system/menu';
|
||||
import { transformMenuToRoutes } from '@/utils/router-helper';
|
||||
|
||||
export const usePermissionStore = defineStore('permission', () => {
|
||||
// State
|
||||
const permissionRoutes = ref<RouteRecordRaw[]>([]);
|
||||
const isRouteAdded = ref(false);
|
||||
const sidebarMenus = ref<MenuInfo[]>([]);
|
||||
|
||||
// Actions
|
||||
/**
|
||||
* 生成并添加路由
|
||||
* 在登录后或刷新页面时调用
|
||||
*/
|
||||
async function generateRoutes() {
|
||||
if (isRouteAdded.value) {
|
||||
return permissionRoutes.value;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 从后端获取菜单树(已按当前用户权限过滤)
|
||||
const menuTree = await getRouters();
|
||||
|
||||
// 2. 将菜单树转换为 Vue Router 配置
|
||||
const asyncRoutes = transformMenuToRoutes(menuTree);
|
||||
|
||||
// 3. 合并静态路由(登录页、404 等)
|
||||
permissionRoutes.value = [...constantRoutes, ...asyncRoutes];
|
||||
|
||||
// 4. 生成侧边栏菜单
|
||||
sidebarMenus.value = menuTree;
|
||||
|
||||
// 5. 标记路由已添加(注意:必须在 addRoute 之后)
|
||||
isRouteAdded.value = true;
|
||||
|
||||
return permissionRoutes.value;
|
||||
} catch (error) {
|
||||
console.error('[PermissionStore] 路由加载失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置权限状态(登出时调用)
|
||||
*/
|
||||
function resetPermission() {
|
||||
permissionRoutes.value = [];
|
||||
isRouteAdded.value = false;
|
||||
sidebarMenus.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
permissionRoutes,
|
||||
isRouteAdded,
|
||||
sidebarMenus,
|
||||
generateRoutes,
|
||||
resetPermission
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 路由守卫(Permission Guard)
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useUserStore, usePermissionStore } from '@/stores';
|
||||
import NProgress from 'nprogress';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: constantRoutes // 初始只注册静态路由
|
||||
});
|
||||
|
||||
// 白名单(无需登录即可访问)
|
||||
const whiteList = ['/login', '/register', '/404'];
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const hasToken = userStore.token;
|
||||
|
||||
if (hasToken) {
|
||||
// 已登录
|
||||
if (to.path === '/login') {
|
||||
// 已登录用户访问登录页,重定向到首页
|
||||
next({ path: '/' });
|
||||
NProgress.done();
|
||||
} else {
|
||||
// 检查路由是否已加载
|
||||
if (permissionStore.isRouteAdded) {
|
||||
// 路由已加载,直接放行
|
||||
next();
|
||||
} else {
|
||||
try {
|
||||
// 路由未加载,生成并添加路由
|
||||
await permissionStore.generateRoutes();
|
||||
|
||||
// 动态添加路由
|
||||
permissionStore.permissionRoutes.forEach(route => {
|
||||
router.addRoute(route);
|
||||
});
|
||||
|
||||
// 重新导航到原目标(确保路由已注册)
|
||||
next({ ...to, replace: true });
|
||||
} catch (error) {
|
||||
// 路由加载失败(token 过期/权限异常)
|
||||
await userStore.logout();
|
||||
next(`/login?redirect=${to.path}`);
|
||||
NProgress.done();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未登录
|
||||
if (whiteList.includes(to.path)) {
|
||||
// 在白名单内,直接放行
|
||||
next();
|
||||
} else {
|
||||
// 重定向到登录页
|
||||
next(`/login?redirect=${to.path}`);
|
||||
NProgress.done();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done();
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
### 2.4 菜单树转路由配置
|
||||
|
||||
```typescript
|
||||
// utils/router-helper.ts
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import type { MenuInfo } from '@/types/system';
|
||||
|
||||
/**
|
||||
* 将后端返回的菜单树转换为 Vue Router 配置
|
||||
*/
|
||||
export function transformMenuToRoutes(menuTree: MenuInfo[]): RouteRecordRaw[] {
|
||||
const routes: RouteRecordRaw[] = [];
|
||||
|
||||
menuTree.forEach(menu => {
|
||||
// 只处理目录和菜单类型的节点
|
||||
if (menu.type === 'dir' || menu.type === 'menu') {
|
||||
const route: RouteRecordRaw = {
|
||||
path: menu.path,
|
||||
name: menu.name,
|
||||
component: loadComponent(menu.component),
|
||||
redirect: menu.redirect,
|
||||
meta: {
|
||||
title: menu.name,
|
||||
icon: menu.icon,
|
||||
hidden: menu.visible === '0', // 1=显示,0=隐藏
|
||||
keepAlive: menu.keepAlive === '1',
|
||||
permission: menu.permission // 权限标识
|
||||
},
|
||||
children: []
|
||||
};
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
route.children = transformMenuToRoutes(menu.children);
|
||||
}
|
||||
|
||||
routes.push(route);
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态加载组件(Vite 语法)
|
||||
*/
|
||||
function loadComponent(componentPath?: string) {
|
||||
if (!componentPath) {
|
||||
return () => import('@/layouts/default/index.vue');
|
||||
}
|
||||
|
||||
// 支持多种组件路径格式
|
||||
const paths = [
|
||||
`@/views/${componentPath}`,
|
||||
`@/views/${componentPath}/index.vue`,
|
||||
componentPath
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
return () => import(/* @vite-ignore */ path);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回 404 组件
|
||||
console.warn(`[RouterHelper] 组件加载失败:${componentPath}`);
|
||||
return () => import('@/views/error-page/404.vue');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、按钮级权限控制
|
||||
|
||||
### 3.1 `v-hasPermi` 指令实现
|
||||
|
||||
```typescript
|
||||
// directives/permission/index.ts
|
||||
import type { App, Directive, DirectiveBinding } from 'vue';
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
|
||||
/**
|
||||
* 全局权限指令
|
||||
* 用法:<a-button v-hasPermi="['ops:work-order:create']">新增</a-button>
|
||||
*/
|
||||
const hasPermission: Directive = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding<string[]>) {
|
||||
const { value } = binding;
|
||||
|
||||
if (value) {
|
||||
const requiredPermissions = Array.isArray(value) ? value : [value];
|
||||
const userStore = useUserStore();
|
||||
const allPermissions = userStore.permissions; // 用户拥有的所有权限标识
|
||||
|
||||
// 检查是否拥有任一权限
|
||||
const hasPermission = requiredPermissions.some(permission =>
|
||||
allPermissions.includes(permission)
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
// 无权限,移除 DOM 元素
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
} else {
|
||||
throw new Error('[Directive] v-hasPermi 需要传入权限标识数组');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册全局指令
|
||||
*/
|
||||
export function setupPermissionDirective(app: App) {
|
||||
app.directive('hasPermi', hasPermission);
|
||||
}
|
||||
|
||||
export default hasPermission;
|
||||
```
|
||||
|
||||
### 3.2 `usePermission` Hook
|
||||
|
||||
对于不能在模板中使用指令的场景(如动态渲染表格列、JS 逻辑判断),使用 Hook:
|
||||
|
||||
```typescript
|
||||
// hooks/web/usePermission.ts
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
|
||||
export function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定权限
|
||||
* @param permission 权限标识
|
||||
* @returns 是否拥有权限
|
||||
*/
|
||||
function hasPermission(permission: string): boolean {
|
||||
return userStore.permissions.includes(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否拥有任一权限
|
||||
* @param permissions 权限标识数组
|
||||
* @returns 是否拥有任一权限
|
||||
*/
|
||||
function hasAnyPermission(permissions: string[]): boolean {
|
||||
return permissions.some(permission =>
|
||||
userStore.permissions.includes(permission)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否拥有所有权限
|
||||
* @param permissions 权限标识数组
|
||||
* @returns 是否拥有所有权限
|
||||
*/
|
||||
function hasAllPermissions(permissions: string[]): boolean {
|
||||
return permissions.every(permission =>
|
||||
userStore.permissions.includes(permission)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
hasAllPermissions
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 使用示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="work-order-detail">
|
||||
<!-- 按钮级权限:指令方式 -->
|
||||
<a-button v-hasPermi="['ops:work-order:update']" @click="handleEdit">
|
||||
编辑
|
||||
</a-button>
|
||||
|
||||
<a-button v-hasPermi="['ops:work-order:audit']" @click="handleAudit">
|
||||
审核
|
||||
</a-button>
|
||||
|
||||
<a-button v-hasPermi="['ops:work-order:delete']" @click="handleDelete">
|
||||
删除
|
||||
</a-button>
|
||||
|
||||
<!-- 表格列权限:Hook 方式 -->
|
||||
<a-table :columns="columns" :data-source="dataSource">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="hasPermission('ops:work-order:dispatch')"
|
||||
@click="handleDispatch(record)"
|
||||
>
|
||||
派单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
const columns = ref([
|
||||
{ title: '工单号', dataIndex: 'orderNo', key: 'orderNo' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '操作', key: 'action' } // 操作列根据权限动态渲染
|
||||
]);
|
||||
|
||||
const dataSource = ref<WorkOrder[]>([]);
|
||||
|
||||
function handleDispatch(record: WorkOrder) {
|
||||
// 派单逻辑
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据权限(Data Scope)
|
||||
|
||||
数据权限控制用户能看到哪些数据(如:只能看本部门的工单)。
|
||||
**数据权限的核心在后端**,但前端需要配合处理展示逻辑。
|
||||
|
||||
### 4.1 后端数据范围类型
|
||||
|
||||
```java
|
||||
// 后端数据范围枚举(DataScopeEnum)
|
||||
public enum DataScopeEnum {
|
||||
ALL, // 全部数据权限
|
||||
CUSTOM_DEPT, // 自定义部门数据权限
|
||||
SINGLE_DEPT, // 本部门数据权限
|
||||
DEPT_AND_CHILD, // 本部门及以下数据权限
|
||||
ONLY_SELF // 仅本人数据权限
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 前端处理策略
|
||||
|
||||
```typescript
|
||||
// stores/user.ts
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// State
|
||||
const user = ref<UserInfo | null>(null);
|
||||
const permissions = ref<string[]>([]);
|
||||
const dataScope = ref<DataScopeEnum>('ONLY_SELF');
|
||||
const deptId = ref<number | null>(null);
|
||||
|
||||
// Actions
|
||||
async function login(username: string, password: string) {
|
||||
const result = await loginApi({ username, password });
|
||||
|
||||
// 保存用户信息
|
||||
user.value = result.user;
|
||||
permissions.value = result.permissions;
|
||||
dataScope.value = result.dataScope;
|
||||
deptId.value = result.deptId;
|
||||
|
||||
// 保存 token
|
||||
token.value = result.token;
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
permissions,
|
||||
dataScope,
|
||||
deptId,
|
||||
login,
|
||||
// ...
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 4.3 前端展示适配
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="work-order-list">
|
||||
<!-- 根据数据范围显示不同提示 -->
|
||||
<a-alert
|
||||
v-if="userStore.dataScope === 'ONLY_SELF'"
|
||||
message="当前仅显示您创建的工单"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<a-alert
|
||||
v-else-if="userStore.dataScope === 'SINGLE_DEPT'"
|
||||
message="当前显示本部门的工单"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- 筛选器:根据数据范围控制可选部门 -->
|
||||
<a-form :model="searchForm" inline>
|
||||
<a-form-item label="所属部门">
|
||||
<a-select
|
||||
v-model:value="searchForm.deptId"
|
||||
:options="deptOptions"
|
||||
:disabled="userStore.dataScope === 'ONLY_SELF'"
|
||||
placeholder="请选择部门"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table :columns="columns" :data-source="dataSource" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const searchForm = ref({
|
||||
deptId: undefined
|
||||
});
|
||||
|
||||
// 部门下拉选项(根据数据范围过滤)
|
||||
const deptOptions = computed(() => {
|
||||
if (userStore.dataScope === 'ONLY_SELF') {
|
||||
return []; // 仅本人,不显示部门选择
|
||||
}
|
||||
|
||||
// 其他情况显示有权限的部门列表
|
||||
return deptList.value.map(dept => ({
|
||||
label: dept.name,
|
||||
value: dept.id
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、权限配置同步流程
|
||||
|
||||
当后端新增权限时,前后端需要同步更新:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[后端开发] -->|1. 新增 Controller 方法 | B[添加@PreAuthorize 注解]
|
||||
B --> C[编写 SQL 插入菜单/按钮记录]
|
||||
C --> D[提交后端代码]
|
||||
|
||||
D --> E{是否需要前端 UI?}
|
||||
E -->|是 | F[前端开发:新增页面/按钮]
|
||||
E -->|否 | G[无需前端改动]
|
||||
|
||||
F --> H[使用 v-hasPermi 绑定权限标识]
|
||||
H --> I[提交前端代码]
|
||||
|
||||
I --> J[测试环境验证]
|
||||
J --> K[生产环境部署]
|
||||
K --> L[管理员分配权限给角色]
|
||||
```
|
||||
|
||||
### 5.1 后端 SQL 示例
|
||||
|
||||
```sql
|
||||
-- 新增菜单记录(系统管理 -> 工单管理 -> 工单审核按钮)
|
||||
INSERT INTO system_menu (
|
||||
parent_id, name, type, path, component,
|
||||
permission, visible, status
|
||||
) VALUES (
|
||||
(SELECT id FROM system_menu WHERE name = '工单管理'), -- 父菜单 ID
|
||||
'工单审核',
|
||||
'button',
|
||||
'',
|
||||
'',
|
||||
'ops:work-order:audit', -- 权限标识(必须与后端@PreAuthorize 一致)
|
||||
'1',
|
||||
'0'
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 前端权限标识对齐检查清单
|
||||
|
||||
- [ ] 权限标识字符串与后端 `@PreAuthorize` 注解完全一致
|
||||
- [ ] 权限标识已录入 SQL 初始化脚本
|
||||
- [ ] 前端按钮使用 `v-hasPermi` 而非 `v-if="role === 'xxx'"`
|
||||
- [ ] 新增的菜单/按钮已在后端 `system/menu/get-routers` 返回的树中
|
||||
- [ ] 测试账号已分配对应角色并验证权限生效
|
||||
|
||||
---
|
||||
|
||||
## 六、常见坑点与排障
|
||||
|
||||
### 6.1 路由已加载但菜单不显示
|
||||
|
||||
**现象**:用户有权限访问页面,但左侧菜单树看不到入口。
|
||||
|
||||
**原因**:后端 `system/menu/get-routers` 接口返回的菜单树中,该菜单的 `visible` 字段为 `0`(隐藏)。
|
||||
|
||||
**排查**:
|
||||
```sql
|
||||
-- 检查菜单可见性
|
||||
SELECT id, name, visible FROM system_menu WHERE permission = 'ops:work-order:list';
|
||||
```
|
||||
|
||||
**解决**:将 `visible` 改为 `1`,或在前端路由配置中设置 `meta.hidden = false`。
|
||||
|
||||
### 6.2 按钮不显示但接口能调通
|
||||
|
||||
**现象**:按钮被 `v-hasPermi` 隐藏,但直接在浏览器调用接口能成功。
|
||||
|
||||
**原因**:用户未分配对应权限,但后端接口未加 `@PreAuthorize` 注解。
|
||||
|
||||
**排查**:
|
||||
1. 检查前端 `userStore.permissions` 是否包含该权限标识
|
||||
2. 检查后端 Controller 方法是否有 `@PreAuthorize` 注解
|
||||
|
||||
**风险**:这是严重的安全漏洞!前端隐藏只是防君子,后端必须加权限校验。
|
||||
|
||||
### 6.3 刷新页面后权限丢失
|
||||
|
||||
**现象**:登录正常,刷新页面后路由加载失败,重定向到登录页。
|
||||
|
||||
**原因**:Pinia Store 状态在刷新后丢失,但 token 还在 localStorage 中。
|
||||
|
||||
**解决**:在 `main.ts` 或路由守卫中,检查 token 存在但 Store 为空时,重新获取用户信息:
|
||||
|
||||
```typescript
|
||||
// stores/user.ts
|
||||
async function initUserInfo() {
|
||||
if (!this.token) return;
|
||||
|
||||
try {
|
||||
const userInfo = await getUserInfoApi();
|
||||
this.user = userInfo.user;
|
||||
this.permissions = userInfo.permissions;
|
||||
this.dataScope = userInfo.dataScope;
|
||||
} catch {
|
||||
// 获取失败,清除 token 并跳转登录
|
||||
await this.logout();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 权限标识大小写问题
|
||||
|
||||
**现象**:后端注解是 `'ops:work-order:list'`,前端写成了 `'ops:Work-Order:List'`。
|
||||
|
||||
**解决**:权限标识统一使用**小写字母 + 连字符**,在团队规范中明确约定。
|
||||
|
||||
---
|
||||
|
||||
## 七、相关代码入口
|
||||
|
||||
| 模块 | 文件路径 | 说明 |
|
||||
|-----|---------|------|
|
||||
| 权限 Store | `src/stores/modules/permission.ts` | 动态路由加载 |
|
||||
| 用户 Store | `src/stores/modules/user.ts` | 用户信息与权限列表 |
|
||||
| 权限指令 | `src/directives/permission/index.ts` | `v-hasPermi` 实现 |
|
||||
| 权限 Hook | `src/hooks/web/usePermission.ts` | JS 内权限判断 |
|
||||
| 路由守卫 | `src/router/index.ts` | 登录态与路由检查 |
|
||||
| 路由工具 | `src/utils/router-helper.ts` | 菜单树转路由配置 |
|
||||
| 菜单 API | `src/api/system/menu.ts` | `getRouters()` 接口 |
|
||||
|
||||
---
|
||||
|
||||
## 八、安全红线
|
||||
|
||||
| 红线项 | 说明 | 后果 |
|
||||
|-------|------|------|
|
||||
| **前端隐藏代替后端校验** | 按钮隐藏不等于接口安全 | 未授权访问 |
|
||||
| **硬编码角色名** | `v-if="role === 'admin'"` | 权限变更需发版 |
|
||||
| **权限标识不一致** | 前后端字符串对不上 | 权限失效或越权 |
|
||||
| **菜单 SQL 漏配置** | 新增权限忘记插菜单记录 | 用户看不到入口 |
|
||||
| **刷新后不恢复权限** | Store 丢失未重新获取 | 用户被迫重新登录 |
|
||||
Reference in New Issue
Block a user