diff --git a/开发者文档/04-前端开发/01-前端工程结构与协作边界.md b/开发者文档/04-前端开发/01-前端工程结构与协作边界.md index 0879ecd..2fb7bfc 100644 --- a/开发者文档/04-前端开发/01-前端工程结构与协作边界.md +++ b/开发者文档/04-前端开发/01-前端工程结构与协作边界.md @@ -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/`,缩小爆炸半径。 \ No newline at end of file +**好处**:当后端 `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 + + + + + +``` + +**禁止在共享层组件中**: +- 直接发起 Axios 请求 +- 直接访问 Pinia Store +- 硬编码业务逻辑 + +```vue + + + +``` + +**违规处理**:如果需要在组件内调接口或访问 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 + +# 检查是否存在重复依赖 +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) - 踩坑记录与调试技巧 diff --git a/开发者文档/04-前端开发/02-API 交互与状态管理规范.md b/开发者文档/04-前端开发/02-API 交互与状态管理规范.md new file mode 100644 index 0000000..fc54e21 --- /dev/null +++ b/开发者文档/04-前端开发/02-API 交互与状态管理规范.md @@ -0,0 +1,1025 @@ +# 02-API 交互与状态管理规范 + +本文档定义前端与后端 API 的交互规范、TypeScript 类型契约、Pinia 状态管理的使用原则,以及异常处理的统一策略。 + +--- + +## 一、HTTP 请求封装 + +### 1.1 Axios 实例配置 + +```typescript +// utils/http/axios/index.ts +import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'; +import { useUserStore } from '@/stores/modules/user'; +import { Message, Modal } from 'ant-design-vue'; +import { resultEnum } from '@/enums/httpEnum'; + +// 创建 Axios 实例 +const axiosInstance = axios.create({ + baseURL: import.meta.env.VITE_GLOB_API_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json;charset=UTF-8' + } +}); + +// 请求拦截器 +axiosInstance.interceptors.request.use((config) => { + const userStore = useUserStore(); + const token = userStore.token; + + // 添加认证头 + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + + // 添加租户 ID(多租户场景) + if (userStore.tenantId) { + config.headers['tenant-id'] = userStore.tenantId; + } + + return config; +}, (error) => { + return Promise.reject(error); +}); + +// 响应拦截器 +axiosInstance.interceptors.response.use( + (response) => { + const res = response.data; + + // 二进制文件直接返回 + if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') { + return response; + } + + // 业务错误处理 + if (res.code !== resultEnum.SUCCESS.code) { + const message = res.msg || res.message || '请求失败'; + + // 401: 未授权,清除 token 并跳转登录 + if (res.code === resultEnum.UNAUTHORIZED.code) { + const userStore = useUserStore(); + Modal.error({ + title: '登录已过期', + content: '请重新登录', + onOk: () => { + userStore.logout(); + window.location.href = '/login'; + } + }); + } + // 403: 无权限 + else if (res.code === resultEnum.FORBIDDEN.code) { + Message.error('无访问权限'); + } + // 其他业务错误 + else { + Message.error(message); + } + + return Promise.reject(new Error(message)); + } + + return res; + }, + (error) => { + // HTTP 错误处理 + let message = '网络异常,请稍后重试'; + + if (error.response) { + switch (error.response.status) { + case 400: + message = '请求参数错误'; + break; + case 401: + message = '未授权,请重新登录'; + break; + case 403: + message = '拒绝访问'; + break; + case 404: + message = '请求地址不存在'; + break; + case 500: + message = '服务器内部错误'; + break; + case 502: + message = '网关错误'; + break; + case 503: + message = '服务不可用'; + break; + case 504: + message = '网关超时'; + break; + default: + message = `连接错误${error.response.status}`; + } + } else if (error.request) { + message = '网络异常,请检查网络连接'; + } + + Message.error(message); + return Promise.reject(error); + } +); + +// 封装请求方法 +class Request { + private instance: AxiosInstance; + + constructor(instance: AxiosInstance) { + this.instance = instance; + } + + get(config: AxiosRequestConfig): Promise { + return this.instance.request({ ...config, method: 'GET' }); + } + + post(config: AxiosRequestConfig): Promise { + return this.instance.request({ ...config, method: 'POST' }); + } + + put(config: AxiosRequestConfig): Promise { + return this.instance.request({ ...config, method: 'PUT' }); + } + + delete(config: AxiosRequestConfig): Promise { + return this.instance.request({ ...config, method: 'DELETE' }); + } + + upload(config: AxiosRequestConfig): Promise { + return this.instance.request({ + ...config, + method: 'POST', + headers: { 'Content-Type': 'multipart/form-data' } + }); + } + + download(config: AxiosRequestConfig): Promise { + return this.instance.request({ + ...config, + method: 'GET', + responseType: 'blob' + }); + } +} + +export const request = new Request(axiosInstance); +export default axiosInstance; +``` + +--- + +## 二、API 模块化管理 + +### 2.1 目录结构 + +API 按业务域组织,与后端微服务模块对齐: + +``` +src/api/ +├── system/ # 系统管理(用户、角色、菜单) +│ ├── user.ts +│ ├── role.ts +│ ├── menu.ts +│ ├── dept.ts +│ └── dict.ts +├── ops/ # Ops 工单(保洁、安保、巡检) +│ ├── work-order.ts +│ ├── cleaner.ts +│ ├── security.ts +│ ├── inspection.ts +│ └── queue.ts +├── iot/ # IoT 设备 +│ ├── device.ts +│ ├── thing-model.ts +│ ├── rule.ts +│ └── badge.ts +├── infra/ # 基础设施(文件、定时任务) +│ ├── file.ts +│ └── job.ts +└── types/ # 公共类型定义 + └── index.ts +``` + +### 2.2 API 文件规范 + +每个 API 模块文件包含: +1. 请求参数类型定义(ReqVO) +2. 响应数据类型定义(ResVO / DO) +3. 请求方法封装 + +```typescript +// api/ops/work-order.ts +import { request } from '@/utils/http/axios'; +import type { PageResult } from '@/types'; + +// ==================== 类型定义 ==================== + +/** + * 工单列表请求参数 + */ +export interface WorkOrderListReqVO { + pageNum: number; + pageSize: number; + orderNo?: string; // 工单号 + type?: number; // 工单类型(1=保洁,2=安保,3=维修) + status?: number; // 工单状态 + executorId?: number; // 执行人 ID + createTime?: [string, string]; // 创建时间范围 +} + +/** + * 工单详情响应 + */ +export interface WorkOrderDetailResVO { + id: number; + orderNo: string; + type: number; + typeName: string; + status: number; + statusName: string; + priority: number; + priorityName: string; + title: string; + description: string; + executorId?: number; + executorName?: string; + location?: string; + locationAddress?: string; + beaconMac?: string; + expectedFinishTime?: number; + actualFinishTime?: number; + createTime: number; + creatorName: string; + images: string[]; +} + +/** + * 创建工单请求 + */ +export interface WorkOrderCreateReqVO { + type: number; + title: string; + description: string; + priority?: number; + location?: string; + locationAddress?: string; + expectedFinishTime?: number; + imageFiles?: File[]; +} + +/** + * 工单状态变更请求 + */ +export interface WorkOrderStatusChangeReqVO { + status: number; + remark?: string; + images?: string[]; +} + +// ==================== API 方法 ==================== + +/** + * 获取工单列表 + */ +export function getWorkOrderList(params: WorkOrderListReqVO) { + return request.get>({ + url: '/ops/work-order/list', + params + }); +} + +/** + * 获取工单详情 + */ +export function getWorkOrderDetail(id: number) { + return request.get({ + url: `/ops/work-order/${id}` + }); +} + +/** + * 创建工单 + */ +export function createWorkOrder(data: WorkOrderCreateReqVO) { + return request.post({ + url: '/ops/work-order/create', + data + }); +} + +/** + * 更新工单 + */ +export function updateWorkOrder(id: number, data: Partial) { + return request.put({ + url: `/ops/work-order/${id}/update`, + data + }); +} + +/** + * 删除工单 + */ +export function deleteWorkOrder(id: number) { + return request.delete({ + url: `/ops/work-order/${id}/delete`, + params: { id } + }); +} + +/** + * 工单状态变更(接单/完工/审核等) + */ +export function changeWorkOrderStatus(id: number, data: WorkOrderStatusChangeReqVO) { + return request.put({ + url: `/ops/work-order/${id}/status`, + data + }); +} + +/** + * 工单派单 + */ +export function dispatchWorkOrder(orderId: number, executorId: number) { + return request.post({ + url: `/ops/work-order/${orderId}/dispatch`, + data: { executorId } + }); +} + +/** + * 导出工单列表 + */ +export function exportWorkOrderList(params: WorkOrderListReqVO) { + return request.download({ + url: '/ops/work-order/export-excel', + params + }); +} +``` + +### 2.3 公共类型定义 + +```typescript +// types/index.ts + +/** + * 分页响应 + */ +export interface PageResult { + list: T[]; + total: number; + pageNum: number; + pageSize: number; + pages: number; +} + +/** + * 通用响应 + */ +export interface ApiResponse { + code: number; + msg: string; + data: T; +} + +/** + * 时间范围选择 + */ +export type DateRange = [string, string] | null; + +/** + * 文件上传响应 + */ +export interface UploadResult { + url: string; + name: string; + size: number; +} +``` + +--- + +## 三、页面调用规范 + +### 3.1 列表页标准写法 + +```vue + + + +``` + +--- + +## 四、Pinia 状态管理 + +### 4.1 什么数据应该进 Store + +**应该进 Store 的数据**: +- ✅ 登录信息(token、用户信息、权限列表) +- ✅ 全局配置(系统设置、主题配置) +- ✅ 字典数据(工单类型、状态枚举等) +- ✅ 动态路由和菜单 +- ✅ 跨页面共享的业务状态 + +**不应该进 Store 的数据**: +- ❌ 列表页的翻页参数(`pageNum`、`pageSize`) +- ❌ 表单的中间输入状态 +- ❌ 单个页面的详情数据 +- ❌ 临时计算结果 + +**判断标准**:刷新页面后是否还需要保留?多个页面是否共享? + +### 4.2 用户 Store + +```typescript +// stores/modules/user.ts +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import { loginApi, getUserInfoApi, logoutApi } from '@/api/system/auth'; +import { setToken, removeToken, getToken } from '@/utils/auth'; + +export interface UserInfo { + id: number; + username: string; + nickname: string; + avatar: string; + email: string; + phone: string; + sex: number; + deptId: number; + deptName: string; +} + +export const useUserStore = defineStore('user', () => { + // State + const token = ref(getToken() || ''); + const user = ref(null); + const permissions = ref([]); + const roles = ref([]); + const dataScope = ref('ONLY_SELF'); + + // Getters + const isLoggedIn = computed(() => !!token.value); + const userName = computed(() => user.value?.nickname || user.value?.username || ''); + const userAvatar = computed(() => user.value?.avatar || ''); + + // Actions + /** + * 登录 + */ + async function login(username: string, password: string) { + const result = await loginApi({ username, password, grantType: 'password' }); + + token.value = result.accessToken; + setToken(result.accessToken); + + // 登录成功后获取用户信息 + await getUserInfo(); + + return result; + } + + /** + * 获取用户信息 + */ + async function getUserInfo() { + const result = await getUserInfoApi(); + + user.value = result.user; + permissions.value = result.permissions; + roles.value = result.roles; + dataScope.value = result.dataScope; + } + + /** + * 登出 + */ + async function logout() { + try { + await logoutApi(); + } catch { + // 忽略登出接口错误 + } finally { + resetState(); + } + } + + /** + * 重置状态 + */ + function resetState() { + token.value = ''; + user.value = null; + permissions.value = []; + roles.value = []; + dataScope.value = 'ONLY_SELF'; + removeToken(); + } + + return { + token, + user, + permissions, + roles, + dataScope, + isLoggedIn, + userName, + userAvatar, + login, + getUserInfo, + logout, + resetState + }; +}); +``` + +### 4.3 字典 Store + +```typescript +// stores/modules/dict.ts +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import { getDictDataApi } from '@/api/system/dict'; + +export interface DictOption { + label: string; + value: string | number; + color?: string; +} + +export const useDictStore = defineStore('dict', () => { + // State: 字典数据缓存 + const dictMap = ref>({}); + + // Getters + const getDict = computed(() => (type: string) => dictMap.value[type] || []); + + // Actions + /** + * 加载字典 + * @param types 字典类型数组,如 ['ops_work_order_type', 'ops_work_order_status'] + */ + async function loadDict(types: string[]) { + const needLoadTypes = types.filter(type => !dictMap.value[type]); + + if (needLoadTypes.length === 0) return; + + try { + const promises = needLoadTypes.map(type => getDictDataApi(type)); + const results = await Promise.all(promises); + + results.forEach((result, index) => { + const type = needLoadTypes[index]; + dictMap.value[type] = result.map(item => ({ + label: item.label, + value: item.value, + color: item.colorType + })); + }); + } catch (error) { + console.error('[DictStore] 加载字典失败:', error); + } + } + + /** + * 获取字典标签 + * @param type 字典类型 + * @param value 字典值 + */ + function getDictLabel(type: string, value: string | number): string { + const dict = dictMap.value[type] || []; + const option = dict.find(item => item.value === value); + return option?.label || String(value); + } + + return { + dictMap, + getDict, + loadDict, + getDictLabel + }; +}); +``` + +### 4.4 页面中使用 Store + +```vue + +``` + +--- + +## 五、文件上传 + +### 5.1 单文件上传 + +```vue + + + +``` + +### 5.2 多文件上传(工单图片) + +```vue + + + +``` + +--- + +## 六、异常处理最佳实践 + +### 6.1 统一拦截器处理 + +所有 HTTP 异常已在 Axios 拦截器中统一处理,**页面代码中不需要重复处理**: + +```typescript +// ❌ 错误:每个页面都写一遍错误处理 +try { + await getWorkOrderList(params); +} catch (error) { + Message.error('加载失败'); +} + +// ✅ 正确:直接调用,错误由拦截器处理 +await getWorkOrderList(params); +``` + +### 6.2 需要特殊处理的场景 + +只有以下情况需要在页面中处理异常: + +```typescript +// 1. 需要静默失败(不提示错误) +try { + await someApi(params); +} catch { + // 静默失败,不做任何提示 +} + +// 2. 需要根据错误类型做不同处理 +try { + await login(username, password); +} catch (error: any) { + if (error.response?.status === 401) { + // 密码错误,清空密码框 + password.value = ''; + } +} + +// 3. 需要自定义错误提示 +try { + await deleteWorkOrder(id); +} catch { + // 使用自定义提示 + Modal.success({ + title: '删除成功', + content: '工单已移至回收站' + }); +} +``` + +--- + +## 七、相关代码入口 + +| 模块 | 文件路径 | 说明 | +|-----|---------|------| +| Axios 封装 | `src/utils/http/axios/index.ts` | 请求/响应拦截器 | +| 工单 API | `src/api/ops/work-order.ts` | 工单相关接口 | +| 用户 Store | `src/stores/modules/user.ts` | 登录态与用户信息 | +| 字典 Store | `src/stores/modules/dict.ts` | 字典数据缓存 | +| 权限指令 | `src/directives/permission/index.ts` | `v-hasPermi` | +| 文件上传 | `src/api/infra/file.ts` | 文件上传接口 | diff --git a/开发者文档/04-前端开发/02-API交互与状态管理规范.md b/开发者文档/04-前端开发/02-API交互与状态管理规范.md index b758742..cd47409 100644 --- a/开发者文档/04-前端开发/02-API交互与状态管理规范.md +++ b/开发者文档/04-前端开发/02-API交互与状态管理规范.md @@ -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 + +``` + +--- + +## 二、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 diff --git a/开发者文档/04-前端开发/03-RBAC 权限控制与开发规范.md b/开发者文档/04-前端开发/03-RBAC 权限控制与开发规范.md new file mode 100644 index 0000000..a2a604a --- /dev/null +++ b/开发者文档/04-前端开发/03-RBAC 权限控制与开发规范.md @@ -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 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: 将菜单树转换为路由配置
(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([]); + const isRouteAdded = ref(false); + const sidebarMenus = ref([]); + + // 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'; + +/** + * 全局权限指令 + * 用法:新增 + */ +const hasPermission: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + 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 + + + +``` + +--- + +## 四、数据权限(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(null); + const permissions = ref([]); + const dataScope = ref('ONLY_SELF'); + const deptId = ref(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 + + + +``` + +--- + +## 五、权限配置同步流程 + +当后端新增权限时,前后端需要同步更新: + +```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 丢失未重新获取 | 用户被迫重新登录 |