diff --git a/开发者文档/04-前端开发/02-API 交互与状态管理规范.md b/开发者文档/04-前端开发/02-API 交互与状态管理规范.md deleted file mode 100644 index fc54e21..0000000 --- a/开发者文档/04-前端开发/02-API 交互与状态管理规范.md +++ /dev/null @@ -1,1025 +0,0 @@ -# 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-前端开发/03-RBAC 权限控制与开发规范.md b/开发者文档/04-前端开发/03-RBAC 权限控制与开发规范.md deleted file mode 100644 index a2a604a..0000000 --- a/开发者文档/04-前端开发/03-RBAC 权限控制与开发规范.md +++ /dev/null @@ -1,736 +0,0 @@ -# 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 丢失未重新获取 | 用户被迫重新登录 | diff --git a/开发者文档/08-附录/03-Enum 汇总.md b/开发者文档/08-附录/03-Enum 汇总.md new file mode 100644 index 0000000..cda0050 --- /dev/null +++ b/开发者文档/08-附录/03-Enum 汇总.md @@ -0,0 +1,392 @@ +# 03-Enum 汇总 + +本文档汇总 AIOT 系统中所有业务枚举,包括后端 Java 枚举和前端 TypeScript 枚举。 + +--- + +## 一、工单相关枚举 + +### 1.1 工单状态(WorkOrderStatusEnum) + +```java +// Java: WorkOrderStatusEnum.java +public enum WorkOrderStatusEnum { + PENDING("PENDING", "待分配"), + QUEUED("QUEUED", "排队中"), + DISPATCHED("DISPATCHED", "已推送"), + CONFIRMED("CONFIRMED", "已确认"), + ARRIVED("ARRIVED", "已到岗"), + PAUSED("PAUSED", "已暂停"), + COMPLETED("COMPLETED", "已完成"), + CANCELLED("CANCELLED", "已取消"); +} +``` + +```typescript +// TypeScript: api/ops/order-center/index.ts +export enum OrderStatus { + PENDING = 'PENDING', // 待分配 + QUEUED = 'QUEUED', // 排队中 + DISPATCHED = 'DISPATCHED', // 已推送 + CONFIRMED = 'CONFIRMED', // 已确认 + ARRIVED = 'ARRIVED', // 已到岗 + PAUSED = 'PAUSED', // 已暂停 + COMPLETED = 'COMPLETED', // 已完成 + CANCELLED = 'CANCELLED', // 已取消 +} +``` + +**状态流转规则:** + +```mermaid +stateDiagram-v2 + [*] --> PENDING + PENDING --> QUEUED + PENDING --> DISPATCHED + QUEUED --> DISPATCHED + DISPATCHED --> CONFIRMED + CONFIRMED --> ARRIVED + ARRIVED --> COMPLETED + ARRIVED --> PAUSED + PAUSED --> ARRIVED + PENDING --> CANCELLED + QUEUED --> CANCELLED + DISPATCHED --> CANCELLED + CONFIRMED --> CANCELLED + ARRIVED --> CANCELLED + COMPLETED --> [*] + CANCELLED --> [*] +``` + +| 方法 | 说明 | +|-----|------| +| `isTerminal()` | 是否为终态(COMPLETED/CANCELLED) | +| `canConfirm()` | 是否可以确认(仅 DISPATCHED) | +| `canStartWorking()` | 是否可以开始作业(CONFIRMED/DISPATCHED) | +| `canComplete()` | 是否可以完成(仅 ARRIVED) | +| `canPause()` | 是否可以暂停(ARRIVED/CONFIRMED) | +| `canCancel()` | 是否可以取消(非终态) | + +### 1.2 工单类型(WorkOrderTypeEnum) + +```java +// Java +public enum WorkOrderTypeEnum { + CLEAN("CLEAN", "保洁"), + REPAIR("REPAIR", "维修"), + SECURITY("SECURITY", "安保"), + SERVICE("SERVICE", "客服"); +} +``` + +```typescript +// TypeScript +export enum OrderType { + CLEAN = 'CLEAN', // 保洁 + REPAIR = 'REPAIR', // 维修 + SECURITY = 'SECURITY', // 安保 +} +``` + +### 1.3 优先级(PriorityEnum) + +```java +// Java +public enum PriorityEnum { + P0(0, "P0紧急", 0, true), // 可打断 + P1(1, "P1重要", 1, false), + P2(2, "P2普通", 2, false), + P3(3, "P3低优", 3, false); + + private final Integer priority; + private final String description; + private final Integer queueOrder; + private final Boolean canInterrupt; +} +``` + +```typescript +// TypeScript +export enum Priority { + P0 = 0, // 紧急 + P1 = 1, // 重要 + P2 = 2, // 普通 +} +``` + +| 优先级 | 数字值 | 可打断 | 场景 | +|-------|-------|-------|------| +| P0 | 0 | ✅ | 溢水、危险品泄漏、严重安全事故 | +| P1 | 1 | ❌ | VIP投诉、重要访客、设备故障 | +| P2 | 2 | ❌ | 日常清洁超时、周期性保养 | +| P3 | 3 | ❌ | 日常巡检、例行清洁 | + +### 1.4 触发来源(SourceTypeEnum) + +```java +// Java +public enum SourceTypeEnum { + TRAFFIC("TRAFFIC", "系统触发"), + INSPECTION("INSPECTION", "巡检发现"), + MANUAL("MANUAL", "手动创建"), + SCHEDULE("SCHEDULE", "定时排班"), + ALARM("ALARM", "告警触发"); +} +``` + +```typescript +// TypeScript +export enum TriggerSource { + IOT_BEACON = 'IOT_BEACON', // 蓝牙信标 + IOT_TRAFFIC = 'IOT_TRAFFIC', // 客流阈值 + TRAFFIC = 'TRAFFIC', // 客流阈值 + VIDEO_ALARM = 'VIDEO_ALARM', // 视频告警 + ACCESS_ALARM = 'ACCESS_ALARM', // 门禁告警 + PATROL_ALARM = 'PATROL_ALARM', // 巡更告警 + PANIC_BUTTON = 'PANIC_BUTTON', // 紧急按钮 + MANUAL = 'MANUAL', // 手动创建 +} +``` + +--- + +## 二、工牌相关枚举 + +### 2.1 工牌状态(BadgeDeviceStatusEnum / CleanerStatusEnum) + +```java +// Java: BadgeDeviceStatusEnum.java / CleanerStatusEnum.java +public enum BadgeDeviceStatusEnum { + IDLE("idle", "空闲", true), // 可接新单 + BUSY("busy", "忙碌", false), + PAUSED("paused", "暂停", false), + OFFLINE("offline", "离线", false); + + private final String code; + private final String description; + private final boolean canAcceptNewOrder; +} +``` + +```typescript +// TypeScript: api/ops/cleaning/index.ts +export enum BadgeStatus { + IDLE = 'IDLE', // 空闲 + BUSY = 'BUSY', // 忙碌 + OFFLINE = 'OFFLINE', // 离线 + PAUSED = 'PAUSED', // 暂停中 +} +``` + +**状态转换规则:** + +| 当前状态 | 可转换到 | +|---------|---------| +| OFFLINE | IDLE | +| IDLE | BUSY, OFFLINE | +| BUSY | PAUSED, IDLE, OFFLINE | +| PAUSED | BUSY, IDLE, OFFLINE | + +### 2.2 工牌通知类型(NotifyTypeEnum) + +```java +// Java +public enum NotifyTypeEnum { + VOICE("VOICE", "语音通知"), + VIBRATE("VIBRATE", "震动通知"); +} +``` + +```typescript +// TypeScript +export enum NotifyType { + VIBRATE = 'VIBRATE', // 震动 + VOICE = 'VOICE', // 语音 +} +``` + +--- + +## 三、巡检相关枚举 + +### 3.1 巡检结果(InspectionResultEnum) + +```java +// Java +public enum InspectionResultEnum { + FAILED(0, "不合格"), + PASSED(1, "合格"); +} +``` + +```typescript +// TypeScript: api/ops/inspection/index.ts +// 0=不合格 1=合格 +isLocationException: number // 0=正常 1=异常 +resultStatus: number // 0=不合格 1=合格 +``` + +### 3.2 巡检归属判定(InspectionAttributionEnum) + +```java +// Java +public enum InspectionAttributionEnum { + PERSONAL(1, "个人责任"), + EMERGENCY(2, "突发状况"), + NORMAL(3, "正常"); +} +``` + +```typescript +// TypeScript +attributionResult?: number // 1=个人责任 2=突发状况 3=正常 +``` + +--- + +## 四、IoT 设备相关枚举 + +### 4.1 设备状态(IotDeviceStateEnum) + +```java +// Java +public enum IotDeviceStateEnum { + INACTIVE(0, "未激活"), + ONLINE(1, "在线"), + OFFLINE(2, "离线"); +} +``` + +| 状态 | 数值 | 说明 | +|-----|------|------| +| 未激活 | 0 | 设备未激活 | +| 在线 | 1 | 设备正常连接 | +| 离线 | 2 | 设备断开连接 | + +### 4.2 设备消息类型(IotDeviceMessageTypeEnum) + +```java +// Java +@Deprecated +public enum IotDeviceMessageTypeEnum { + STATE("state"), // 设备状态 + PROPERTY("property"), // 设备属性 + EVENT("event"), // 设备事件 + SERVICE("service"), // 设备服务 + CONFIG("config"), // 设备配置 + OTA("ota"), // OTA 升级 + REGISTER("register"), // 设备注册 + TOPOLOGY("topology"); // 设备拓扑 +} +``` + +--- + +## 五、系统相关枚举 + +### 5.1 通用状态(CommonStatusEnum) + +```java +// Java +public enum CommonStatusEnum { + ENABLE(0, "开启"), + DISABLE(1, "关闭"); +} +``` + +### 5.2 用户类型(UserTypeEnum) + +```java +// Java +public enum UserTypeEnum { + MEMBER(1, "会员"), + ADMIN(2, "管理员"); +} +``` + +### 5.3 终端类型(TerminalEnum) + +```java +// Java +public enum TerminalEnum { + WEB(1, "Web"), + APP(2, "App"), + MINI_PROGRAM(3, "小程序"); +} +``` + +--- + +## 六、前端专用枚举 + +### 6.1 HTTP 结果码(ResultEnum) + +```typescript +// http/tools/enum.ts + +export enum ResultEnum { + Success0 = 0, // 成功 + Success200 = 200, // 成功 + Error = 400, // 错误 + Unauthorized = 401, // 未授权 + Forbidden = 403, // 禁止访问 + NotFound = 404, // 未找到 + MethodNotAllowed = 405, // 方法不允许 + RequestTimeout = 408, // 请求超时 + InternalServerError = 500, // 服务器错误 + NotImplemented = 501, // 未实现 + BadGateway = 502, // 网关错误 + ServiceUnavailable = 503, // 服务不可用 + GatewayTimeout = 504, // 网关超时 + HttpVersionNotSupported = 505, // HTTP版本不支持 +} +``` + +### 6.2 蓝牙扫描状态(ScanStatus) + +```typescript +// pages-ops/inspection/composables/use-bluetooth-scan.ts + +export type ScanStatus = + | 'idle' // 空闲 + | 'scanning' // 扫描中 + | 'success' // 定位成功 + | 'failed' // 定位失败 + | 'no-permission'; // 缺少权限 +``` + +### 6.3 字典类型(DICT_TYPE) + +```typescript +// utils/constants/dict-enum.ts + +export const DICT_TYPE = { + // OPS 模块 + OPS_ORDER_PRIORITY: 'ops_order_priority', + + // 系统模块 + SYSTEM_USER_SEX: 'system_user_sex', + SYSTEM_MENU_TYPE: 'system_menu_type', + SYSTEM_ROLE_TYPE: 'system_role_type', + + // 基础设施 + INFRA_JOB_STATUS: 'infra_job_status', + INFRA_CONFIG_TYPE: 'infra_config_type', + + // BPM + BPM_PROCESS_INSTANCE_STATUS: 'bpm_process_instance_status', + BPM_TASK_STATUS: 'bpm_task_status', +} as const +``` + +--- + +## 七、枚举对照表 + +### 7.1 工单状态对照 + +| Java | TypeScript | 中文 | +|------|-----------|------| +| PENDING | PENDING | 待分配 | +| QUEUED | QUEUED | 排队中 | +| DISPATCHED | DISPATCHED | 已 \ No newline at end of file