# 前端完整对接文档 - RBAC与PostgREST **版本**: v2.0 **日期**: 2025-11-17 **目标**: Vue 3 + TypeScript + Pinia + Element Plus **后端地址**: `http://172.16.0.55:8073` --- ## 📋 目录 1. [系统架构总览](#1-系统架构总览) 2. [认证流程详解](#2-认证流程详解) 3. [RBAC动态路由对接](#3-rbac动态路由对接) 4. [PostgREST数据访问](#4-postgrest数据访问) 5. [完整代码示例](#5-完整代码示例) 6. [常见问题FAQ](#6-常见问题faq) --- ## 1. 系统架构总览 ### 1.1 整体架构 ``` ┌─────────────────────────────────────────────────────────────┐ │ 前端 (Vue 3) │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 登录页面 │ │ 动态路由 │ │ 数据列表 │ │ │ │ /auth/login │ │ Vue Router │ │ CRUD操作 │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ │ │ │ ① 登录 │ ② 获取路由 │ ③ 数据请求 │ └─────────┼──────────────────┼──────────────────┼──────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 后端 FastAPI (port 8073) │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ POST /auth/ │ │ GET /user/ │ │ 全局异常处理 │ │ │ │ login │ │ routes │ │ PostgREST转发│ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ ┌──────────────┐ │ │ │ │ │ PostgREST │ │ │ │ │ │ (port 3000) │ │ │ │ │ └──────────────┘ │ │ ▼ ▼ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ PostgreSQL Database │ │ │ │ - sso_users (用户表) │ │ │ │ - roles (角色表) │ │ │ │ - user_role (用户-角色关联) │ │ │ │ - sys_routes (路由定义) │ │ │ │ - role_route (角色-路由关联) │ │ │ │ - documents (文档表) │ │ │ │ - ... (其他业务表) │ │ │ └──────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### 1.2 三大核心功能 | 功能 | 端点 | 说明 | |------|------|------| | **认证登录** | `POST /auth/login` | 支持OAuth和密码登录,返回JWT Token | | **动态路由** | `GET /user/routes` 或 `/rbac/user/routes` | 返回用户可访问的路由树 | | **数据访问** | `GET/POST/PATCH/DELETE /{table}` | PostgREST风格的数据库访问 | --- ## 2. 认证流程详解 ### 2.1 登录接口 **端点**: `POST /auth/login` **支持两种登录方式**: 1. **OAuth登录** - IDaaS统一认证(推荐) 2. **密码登录** - 用户名密码认证 #### 2.1.1 OAuth登录请求 ```typescript // OAuth登录请求格式 interface OAuthLoginRequest { userInfo: { sub: string; // 用户唯一标识(必填) username?: string; // 用户名/工号 nickname?: string; // 昵称 email?: string; // 邮箱 phone_number?: string; // 手机号 ou_id?: string; // 组织ID ou_name?: string; // 组织名称 is_leader?: boolean; // 是否领导 }; expiresIn: number; // Token过期时间(秒) area?: string; // 用户地区(仅首次创建时保存) } // 示例 const oauthLogin = async (oauthData: any) => { const response = await axios.post('http://172.16.0.55:8073/auth/login', { userInfo: { sub: 'user123', username: 'zhangsan', nickname: '张三', email: 'zhangsan@example.com', phone_number: '13800138000', ou_id: 'dept001', ou_name: '技术部', is_leader: false }, expiresIn: 3600, area: '梅州' }); return response.data; } ``` #### 2.1.2 密码登录请求 ```typescript // 密码登录请求格式 interface PasswordLoginRequest { username: string; // 用户名(实际是sub字段) password: string; // 密码 } // 示例 const passwordLogin = async (username: string, password: string) => { const response = await axios.post('http://172.16.0.55:8073/auth/login', { username: username, // 注意:这里实际传的是 sub password: password }); return response.data; } ``` #### 2.1.3 登录响应格式 ```typescript // 统一响应格式 interface LoginResponse { success: boolean; data?: { access_token: string; // JWT Token token_type: string; // "Bearer" expires_in: number; // Token过期时间(秒) user_info: { user_id: string; // 用户ID username: string; // 用户名 nick_name: string; // 昵称 email?: string; // 邮箱 phone_number?: string;// 手机号 ou_id: string; // 组织ID ou_name: string; // 组织名称 is_leader: boolean; // 是否领导 user_role: string; // 角色(admin/deptLeader/groupLeader/common) sub: string; // 用户唯一标识 }; }; error?: string; // 错误信息 } // 成功示例 { "success": true, "data": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "user_info": { "user_id": "5", "username": "admin", "nick_name": "管理员", "email": null, "phone_number": null, "ou_id": "default", "ou_name": "未分配部门", "is_leader": true, "user_role": "admin", "sub": "000" } } } // 失败示例 { "success": false, "error": "用户名或密码错误" } ``` ### 2.2 登录状态管理 #### 2.2.1 Pinia Store(stores/user.ts) ```typescript import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import axios from 'axios'; import router from '@/router'; interface UserInfo { user_id: string; username: string; nick_name: string; email?: string; phone_number?: string; ou_id: string; ou_name: string; is_leader: boolean; user_role: string; sub: string; } export const useUserStore = defineStore('user', () => { // 状态 const token = ref(''); const userInfo = ref(null); const routes = ref([]); const menuRoutes = ref([]); const hasLoadedRoutes = ref(false); // 计算属性 const isLoggedIn = computed(() => !!token.value); const isAdmin = computed(() => userInfo.value?.user_role === 'admin'); // 登录 const login = async (credentials: { username: string; password: string }) => { try { const response = await axios.post('/auth/login', credentials); if (response.data.success) { const { access_token, user_info } = response.data.data; // 保存Token和用户信息 token.value = access_token; userInfo.value = user_info; // 持久化到localStorage localStorage.setItem('token', access_token); localStorage.setItem('userInfo', JSON.stringify(user_info)); // 设置axios默认header axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`; return { success: true }; } else { return { success: false, error: response.data.error }; } } catch (error: any) { console.error('登录失败:', error); return { success: false, error: error.response?.data?.error || '登录失败,请稍后重试' }; } }; // 登出 const logout = () => { token.value = ''; userInfo.value = null; routes.value = []; menuRoutes.value = []; hasLoadedRoutes.value = false; localStorage.removeItem('token'); localStorage.removeItem('userInfo'); delete axios.defaults.headers.common['Authorization']; router.push('/login'); }; // 从localStorage恢复登录状态 const restoreLoginState = () => { const savedToken = localStorage.getItem('token'); const savedUserInfo = localStorage.getItem('userInfo'); if (savedToken && savedUserInfo) { token.value = savedToken; userInfo.value = JSON.parse(savedUserInfo); axios.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`; } }; // 获取用户路由(RBAC) const fetchUserRoutes = async () => { if (!token.value) { throw new Error('未登录'); } try { const response = await axios.get('/user/routes'); if (response.data.code === 200) { const routeData = response.data.data.routes; routes.value = routeData; menuRoutes.value = routeData.filter((r: any) => !r.is_hidden); hasLoadedRoutes.value = true; // 动态注册路由 registerDynamicRoutes(routeData); return routeData; } else { throw new Error(response.data.msg || '获取路由失败'); } } catch (error) { console.error('获取用户路由失败:', error); throw error; } }; // 动态注册路由到Vue Router const registerDynamicRoutes = (routeList: any[]) => { const transformRoute = (route: any): any => { const transformed: any = { path: route.route_path, name: route.route_name, meta: { title: route.route_title, icon: route.icon, hidden: route.is_hidden, cache: route.is_cache, ...route.meta } }; // 组件路径映射 if (route.component) { transformed.component = () => import(`@/views/${route.component}.vue`); } // 递归处理子路由 if (route.children && route.children.length > 0) { transformed.children = route.children.map(transformRoute); } return transformed; }; routeList.forEach(route => { const transformedRoute = transformRoute(route); router.addRoute(transformedRoute); }); }; return { token, userInfo, routes, menuRoutes, hasLoadedRoutes, isLoggedIn, isAdmin, login, logout, restoreLoginState, fetchUserRoutes }; }); ``` #### 2.2.2 登录页面组件(views/Login.vue) ```vue ``` --- ## 3. RBAC动态路由对接 ### 3.1 获取用户路由 **端点**: - `GET /user/routes` (别名,推荐) - `GET /rbac/user/routes` (完整路径) **请求头**: ``` Authorization: Bearer {JWT_TOKEN} ``` **响应格式**: ```typescript interface RouteResponse { code: number; // 200表示成功 msg: string; // 操作消息 data: { user_id: number; username: string; routes: RouteInfo[]; // 路由树 }; } interface RouteInfo { id: number; route_path: string; // 路由路径,如 "/home" route_name: string; // 路由名称,如 "Home" component?: string; // 组件路径,如 "views/Home.vue" parent_id?: number; // 父路由ID route_title: string; // 路由标题,用于菜单显示 icon?: string; // 图标,如 "el-icon-house" sort_order: number; // 排序 is_hidden: boolean; // 是否隐藏(不在菜单显示) is_cache: boolean; // 是否缓存 meta?: any; // 其他元信息 children?: RouteInfo[]; // 子路由 } ``` **响应示例**: ```json { "code": 200, "msg": "操作成功", "data": { "user_id": 6, "username": "001", "routes": [ { "id": 1, "route_path": "/", "route_name": "Layout", "component": "layout/index", "route_title": "入口页", "icon": "el-icon-s-home", "sort_order": 1, "is_hidden": false, "is_cache": true, "children": [ { "id": 2, "route_path": "/home", "route_name": "Home", "component": "views/Home", "parent_id": 1, "route_title": "系统首页", "icon": "el-icon-house", "sort_order": 2, "is_hidden": false, "is_cache": true }, { "id": 3, "route_path": "/dashboard", "route_name": "Dashboard", "component": "views/Dashboard", "parent_id": 1, "route_title": "工作台", "icon": "el-icon-data-line", "sort_order": 3, "is_hidden": false, "is_cache": true } ] } ] } } ``` ### 3.2 路由注册最佳实践 #### 3.2.1 路由守卫配置(router/index.ts) ```typescript import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; import { useUserStore } from '@/stores/user'; import { ElMessage } from 'element-plus'; // 静态路由(不需要权限的页面) const constantRoutes: RouteRecordRaw[] = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { title: '登录', hidden: true } }, { path: '/404', name: 'NotFound', component: () => import('@/views/404.vue'), meta: { title: '404', hidden: true } } ]; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: constantRoutes }); // 白名单(不需要登录的页面) const whiteList = ['/login', '/404']; // 全局前置守卫 router.beforeEach(async (to, from, next) => { const userStore = useUserStore(); // 恢复登录状态(从localStorage) if (!userStore.isLoggedIn) { userStore.restoreLoginState(); } // 已登录 if (userStore.isLoggedIn) { if (to.path === '/login') { // 已登录,访问登录页 → 跳转到首页 next({ path: '/home' }); } else { // 检查是否已加载路由 if (!userStore.hasLoadedRoutes) { try { // 获取用户路由 await userStore.fetchUserRoutes(); // 重新导航到目标路由(因为路由刚刚动态注册) next({ ...to, replace: true }); } catch (error) { console.error('获取路由失败:', error); ElMessage.error('获取权限失败,请重新登录'); userStore.logout(); next({ path: '/login' }); } } else { // 路由已加载,正常放行 next(); } } } else { // 未登录 if (whiteList.includes(to.path)) { // 白名单内的页面,直接放行 next(); } else { // 其他页面,跳转到登录 next({ path: '/login', query: { redirect: to.fullPath } }); } } }); // 全局后置钩子 router.afterEach((to) => { // 设置页面标题 document.title = (to.meta.title as string) || '智慧法务系统'; }); export default router; ``` ### 3.3 菜单组件生成 #### 3.3.1 侧边栏菜单组件(components/Sidebar.vue) ```vue ``` #### 3.3.2 菜单项组件(components/SidebarItem.vue) ```vue ``` --- ## 4. PostgREST数据访问 ### 4.1 PostgREST基础 后端使用PostgREST提供RESTful API,所有数据库表都可以通过HTTP访问。 **特点**: - 自动根据表结构生成API - 支持强大的过滤、排序、分页 - 前端请求会被后端全局异常处理器拦截并转发到PostgREST ### 4.2 请求格式 #### 4.2.1 查询数据(GET) ```typescript // 基础查询 GET /{table_name} // 示例:查询文档列表 const fetchDocuments = async () => { const response = await axios.get('/documents', { headers: { 'Authorization': `Bearer ${token}` } }); return response.data; }; // 带过滤条件 GET /documents?user_id=eq.5 // 多个条件(AND) GET /documents?user_id=eq.5&status=eq.0 // 选择字段 GET /documents?select=id,title,created_at // 排序 GET /documents?order=created_at.desc // 分页 GET /documents?limit=20&offset=0 // 组合查询 GET /documents?user_id=eq.5&status=eq.0&select=id,title,created_at&order=created_at.desc&limit=20 ``` #### PostgREST过滤操作符 | 操作符 | 说明 | 示例 | |-------|------|------| | `eq` | 等于 | `id=eq.5` | | `neq` | 不等于 | `status=neq.1` | | `gt` | 大于 | `created_at=gt.2025-01-01` | | `gte` | 大于等于 | `id=gte.10` | | `lt` | 小于 | `updated_at=lt.2025-12-31` | | `lte` | 小于等于 | `id=lte.100` | | `like` | 模糊匹配 | `title=like.*合同*` | | `ilike` | 不区分大小写模糊匹配 | `title=ilike.*WORD*` | | `in` | 在列表中 | `id=in.(1,2,3,4,5)` | | `is` | 是NULL | `deleted_at=is.null` | | `not.is` | 不是NULL | `deleted_at=not.is.null` | #### 4.2.2 创建数据(POST) ```typescript // 创建单条记录 POST /{table_name} Content-Type: application/json { "field1": "value1", "field2": "value2" } // 示例:创建文档 const createDocument = async (data: any) => { const response = await axios.post('/documents', data, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); return response.data; }; // 批量创建 POST /documents Content-Type: application/json [ { "title": "文档1", "user_id": 5 }, { "title": "文档2", "user_id": 5 } ] ``` #### 4.2.3 更新数据(PATCH) ```typescript // 更新记录(需要过滤条件) PATCH /{table_name}?{filter} Content-Type: application/json { "field1": "new_value" } // 示例:更新文档 const updateDocument = async (id: number, data: any) => { const response = await axios.patch(`/documents?id=eq.${id}`, data, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); return response.data; }; ``` #### 4.2.4 删除数据(DELETE) ```typescript // 删除记录(需要过滤条件) DELETE /{table_name}?{filter} // 示例:删除文档 const deleteDocument = async (id: number) => { const response = await axios.delete(`/documents?id=eq.${id}`, { headers: { 'Authorization': `Bearer ${token}` } }); return response.data; }; ``` ### 4.3 Axios封装 #### 4.3.1 请求拦截器(utils/request.ts) ```typescript import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import { ElMessage } from 'element-plus'; import { useUserStore } from '@/stores/user'; // 创建axios实例 const service: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || 'http://172.16.0.55:8073', timeout: 30000 }); // 请求拦截器 service.interceptors.request.use( (config: AxiosRequestConfig) => { const userStore = useUserStore(); // 自动添加Token if (userStore.token) { config.headers = config.headers || {}; config.headers['Authorization'] = `Bearer ${userStore.token}`; } return config; }, (error) => { console.error('请求错误:', error); return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( (response: AxiosResponse) => { const res = response.data; // PostgREST返回数组或对象,不是统一格式 // 如果是数组或对象,直接返回 if (Array.isArray(res) || typeof res === 'object' && !res.code) { return response; } // 统一格式的响应(code/msg/data) if (res.code !== undefined) { if (res.code === 200) { return response; } else { ElMessage.error(res.msg || '操作失败'); return Promise.reject(new Error(res.msg || 'Error')); } } return response; }, (error) => { console.error('响应错误:', error); const userStore = useUserStore(); if (error.response) { switch (error.response.status) { case 401: ElMessage.error('登录已过期,请重新登录'); userStore.logout(); break; case 403: ElMessage.error('没有权限访问'); break; case 404: ElMessage.error('请求的资源不存在'); break; case 500: ElMessage.error('服务器错误'); break; default: ElMessage.error(error.response.data?.msg || '请求失败'); } } else { ElMessage.error('网络错误,请检查网络连接'); } return Promise.reject(error); } ); export default service; ``` #### 4.3.2 API封装示例(api/documents.ts) ```typescript import request from '@/utils/request'; // 文档接口类型定义 export interface Document { id: number; title: string; user_id: number; status: number; created_at: string; updated_at: string; } export interface DocumentQuery { user_id?: number; status?: number; keyword?: string; limit?: number; offset?: number; order?: string; } /** * 查询文档列表 */ export const getDocuments = (query: DocumentQuery = {}) => { const params: any = {}; // 过滤条件 if (query.user_id !== undefined) { params.user_id = `eq.${query.user_id}`; } if (query.status !== undefined) { params.status = `eq.${query.status}`; } if (query.keyword) { params.title = `like.*${query.keyword}*`; } // 排序 if (query.order) { params.order = query.order; } else { params.order = 'created_at.desc'; } // 分页 if (query.limit) { params.limit = query.limit; } if (query.offset) { params.offset = query.offset; } return request({ url: '/documents', method: 'get', params }); }; /** * 获取单个文档 */ export const getDocument = (id: number) => { return request({ url: '/documents', method: 'get', params: { id: `eq.${id}` } }); }; /** * 创建文档 */ export const createDocument = (data: Partial) => { return request({ url: '/documents', method: 'post', data }); }; /** * 更新文档 */ export const updateDocument = (id: number, data: Partial) => { return request({ url: `/documents?id=eq.${id}`, method: 'patch', data }); }; /** * 删除文档 */ export const deleteDocument = (id: number) => { return request({ url: `/documents?id=eq.${id}`, method: 'delete' }); }; ``` ### 4.4 数据权限说明 **重要**: 后端已禁用自动数据隔离,前端需要手动添加过滤条件! #### 普通用户(uploader角色) 普通用户只能访问自己的数据,前端需要手动添加 `user_id` 过滤: ```typescript // ❌ 错误:会查询所有用户的文档 const docs = await axios.get('/documents'); // ✅ 正确:只查询当前用户的文档 const userStore = useUserStore(); const userId = userStore.userInfo?.user_id; const docs = await axios.get(`/documents?user_id=eq.${userId}`); ``` #### 管理员用户 管理员可以访问所有数据,无需添加 `user_id` 过滤。 #### 最佳实践 ```typescript // 在API封装中自动处理权限 export const getDocuments = (query: DocumentQuery = {}) => { const userStore = useUserStore(); const params: any = {}; // 非管理员自动添加user_id过滤 if (!userStore.isAdmin) { params.user_id = `eq.${userStore.userInfo?.user_id}`; } // 其他过滤条件 if (query.status !== undefined) { params.status = `eq.${query.status}`; } return request({ url: '/documents', method: 'get', params }); }; ``` --- ## 5. 完整代码示例 ### 5.1 项目结构 ``` src/ ├── api/ # API接口封装 │ ├── auth.ts # 认证接口 │ ├── documents.ts # 文档接口 │ └── ... ├── components/ # 组件 │ ├── Sidebar.vue # 侧边栏 │ ├── SidebarItem.vue # 菜单项 │ └── ... ├── router/ # 路由 │ └── index.ts ├── stores/ # Pinia状态管理 │ └── user.ts # 用户Store ├── utils/ # 工具函数 │ └── request.ts # Axios封装 ├── views/ # 页面 │ ├── Login.vue # 登录页 │ ├── Home.vue # 首页 │ └── ... ├── App.vue └── main.ts ``` ### 5.2 完整示例:文档列表页面 ```vue ``` --- ## 6. 常见问题FAQ ### 6.1 登录相关 **Q: 登录后Token存在哪里?** A: Token存储在两个地方: 1. Pinia Store的内存状态(`userStore.token`) 2. localStorage持久化存储 ```typescript // 保存Token localStorage.setItem('token', access_token); // 读取Token const token = localStorage.getItem('token'); // 删除Token(登出) localStorage.removeItem('token'); ``` **Q: Token过期如何处理?** A: 后端返回401状态码时,前端自动登出并跳转到登录页: ```typescript // 响应拦截器 if (error.response?.status === 401) { ElMessage.error('登录已过期,请重新登录'); userStore.logout(); router.push('/login'); } ``` **Q: 如何实现自动登录?** A: 在应用启动时从localStorage恢复登录状态: ```typescript // main.ts import { useUserStore } from '@/stores/user'; const app = createApp(App); app.use(pinia); app.use(router); // 恢复登录状态 const userStore = useUserStore(); userStore.restoreLoginState(); app.mount('#app'); ``` ### 6.2 路由相关 **Q: 为什么要动态注册路由?** A: 因为不同用户有不同权限,看到的菜单和可访问的页面不同。通过动态注册路由: 1. 提高安全性(用户只能访问有权限的页面) 2. 减少打包体积(按需加载组件) 3. 灵活配置权限(后端控制) **Q: 动态路由何时加载?** A: 在用户登录成功后,通过路由守卫自动加载: ```typescript router.beforeEach(async (to, from, next) => { if (userStore.isLoggedIn && !userStore.hasLoadedRoutes) { await userStore.fetchUserRoutes(); // 加载路由 next({ ...to, replace: true }); // 重新导航 } }); ``` **Q: `/user/routes` 和 `/rbac/user/routes` 有什么区别?** A: 两者功能完全相同,`/user/routes` 是别名路由,为了兼容前端直接调用。推荐使用 `/user/routes`。 ### 6.3 数据访问相关 **Q: PostgREST查询如何分页?** A: 使用 `limit` 和 `offset` 参数: ```typescript // 第1页,每页20条 GET /documents?limit=20&offset=0 // 第2页,每页20条 GET /documents?limit=20&offset=20 // 第3页,每页20条 GET /documents?limit=20&offset=40 ``` **Q: 如何获取总记录数?** A: PostgREST在响应头 `Content-Range` 返回总数: ```typescript const response = await axios.get('/documents?limit=20&offset=0'); const contentRange = response.headers['content-range']; // 格式: "0-19/156" 表示返回0-19条,总共156条 const total = parseInt(contentRange.split('/')[1]); // 156 ``` **Q: 如何实现模糊搜索?** A: 使用 `like` 或 `ilike` 操作符: ```typescript // 搜索标题包含"合同"的文档 GET /documents?title=like.*合同* // 不区分大小写 GET /documents?title=ilike.*contract* ``` **Q: 普通用户只能看到自己的数据吗?** A: 是的!后端已禁用自动数据隔离,前端必须手动添加 `user_id` 过滤条件: ```typescript const userStore = useUserStore(); // 普通用户:只查询自己的数据 if (!userStore.isAdmin) { const docs = await axios.get(`/documents?user_id=eq.${userStore.userInfo.user_id}`); } // 管理员:查询所有数据 if (userStore.isAdmin) { const docs = await axios.get('/documents'); } ``` ### 6.4 权限控制 **Q: 如何判断用户是否是管理员?** A: 通过 `user_role` 字段判断: ```typescript const userStore = useUserStore(); // 方式1:直接判断 if (userStore.userInfo?.user_role === 'admin') { console.log('管理员'); } // 方式2:使用计算属性 if (userStore.isAdmin) { console.log('管理员'); } ``` **Q: 如何控制按钮显示/隐藏?** A: 使用 `v-if` 指令: ```vue ``` **Q: 如何实现自定义权限指令?** A: 创建Vue自定义指令: ```typescript // directives/permission.ts import { Directive } from 'vue'; import { useUserStore } from '@/stores/user'; export const permission: Directive = { mounted(el, binding) { const userStore = useUserStore(); const { value } = binding; // value 是权限代码,如 'document:delete' if (value && !userStore.userInfo?.permissions?.includes(value)) { el.parentNode?.removeChild(el); } } }; // main.ts import { permission } from '@/directives/permission'; app.directive('permission', permission); // 使用 删除 ``` --- ## 7. 环境配置 ### 7.1 开发环境配置(.env.development) ```env # API基础URL VITE_API_BASE_URL=http://172.16.0.55:8073 # 应用端口 VITE_PORT=5173 # 是否开启Mock VITE_USE_MOCK=false ``` ### 7.2 生产环境配置(.env.production) ```env # API基础URL(生产环境) VITE_API_BASE_URL=https://api.example.com # 应用端口 VITE_PORT=80 # 是否开启Mock VITE_USE_MOCK=false ``` --- ## 8. 测试清单 ### 8.1 登录测试 - [ ] 密码登录成功 - [ ] 密码登录失败(错误提示) - [ ] OAuth登录成功 - [ ] Token自动保存到localStorage - [ ] 刷新页面后自动恢复登录状态 - [ ] Token过期自动跳转登录页 ### 8.2 路由测试 - [ ] 登录后自动加载路由 - [ ] 侧边栏菜单正确显示 - [ ] 无权限路由无法访问(跳转404或登录页) - [ ] 路由跳转正常 - [ ] 页面标题正确显示 ### 8.3 数据访问测试 - [ ] 查询列表成功 - [ ] 分页功能正常 - [ ] 搜索过滤正常 - [ ] 创建数据成功 - [ ] 更新数据成功 - [ ] 删除数据成功 - [ ] 普通用户只能看到自己的数据 - [ ] 管理员可以看到所有数据 --- ## 9. 联系与支持 如有问题,请联系后端团队或查看以下文档: - **RBAC系统总结**: `docs/RBAC/RBAC系统使用总结.md` - **用户管理指南**: `docs/RBAC/用户管理完整指南.md` - **角色权限配置**: `docs/RBAC/角色路由权限分配表.md` --- **文档版本**: v2.0 **创建时间**: 2025-11-17 **维护者**: Claude Code **状态**: ✅ 完整对接文档,包含RBAC和PostgREST所有功能