Files
leaudit-platform-frontend/auth_doc/前端完整对接文档_RBAC与PostgREST.md
2025-11-18 11:06:24 +08:00

1656 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端完整对接文档 - 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 Storestores/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<string>('');
const userInfo = ref<UserInfo | null>(null);
const routes = ref<any[]>([]);
const menuRoutes = ref<any[]>([]);
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
<template>
<div class="login-container">
<el-card class="login-card">
<h2>智慧法务系统</h2>
<el-form :model="loginForm" :rules="loginRules" ref="formRef">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
@click="handleLogin"
style="width: 100%"
>
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { ElMessage } from 'element-plus';
const router = useRouter();
const userStore = useUserStore();
const formRef = ref();
const loading = ref(false);
const loginForm = reactive({
username: '',
password: ''
});
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
};
const handleLogin = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
loading.value = true;
try {
const result = await userStore.login({
username: loginForm.username,
password: loginForm.password
});
if (result.success) {
ElMessage.success('登录成功');
// 获取用户路由
await userStore.fetchUserRoutes();
// 跳转到首页
router.push('/home');
} else {
ElMessage.error(result.error || '登录失败');
}
} catch (error: any) {
ElMessage.error(error.message || '登录失败');
} finally {
loading.value = false;
}
});
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 20px;
}
h2 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
</style>
```
---
## 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
<template>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:unique-opened="true"
router
>
<sidebar-item
v-for="route in menuRoutes"
:key="route.route_path"
:item="route"
/>
</el-menu>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useUserStore } from '@/stores/user';
import SidebarItem from './SidebarItem.vue';
const route = useRoute();
const userStore = useUserStore();
const isCollapse = defineModel<boolean>('collapse', { default: false });
const menuRoutes = computed(() => userStore.menuRoutes);
const activeMenu = computed(() => route.path);
</script>
```
#### 3.3.2 菜单项组件(components/SidebarItem.vue
```vue
<template>
<!-- 有子菜单 -->
<el-sub-menu v-if="hasChildren" :index="item.route_path">
<template #title>
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<span>{{ item.route_title }}</span>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.route_path"
:item="child"
/>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="item.route_path">
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<template #title>{{ item.route_title }}</template>
</el-menu-item>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface RouteInfo {
route_path: string;
route_title: string;
icon?: string;
children?: RouteInfo[];
}
const props = defineProps<{
item: RouteInfo;
}>();
const hasChildren = computed(() => {
return props.item.children && props.item.children.length > 0;
});
</script>
```
---
## 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<Document>) => {
return request({
url: '/documents',
method: 'post',
data
});
};
/**
* 更新文档
*/
export const updateDocument = (id: number, data: Partial<Document>) => {
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
<template>
<div class="document-list">
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :inline="true" :model="searchForm">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="请输入文档标题"
clearable
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" clearable placeholder="请选择状态">
<el-option label="全部" :value="undefined" />
<el-option label="正常" :value="0" />
<el-option label="已删除" :value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="success" @click="handleCreate">新建</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card>
<el-table :data="documentList" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'success' : 'danger'">
{{ row.status === 0 ? '正常' : '已删除' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getDocuments, deleteDocument, type Document } from '@/api/documents';
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined
});
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
});
// 数据列表
const documentList = ref<Document[]>([]);
const loading = ref(false);
// 查询列表
const fetchList = async () => {
loading.value = true;
try {
const response = await getDocuments({
keyword: searchForm.keyword,
status: searchForm.status,
limit: pagination.pageSize,
offset: (pagination.page - 1) * pagination.pageSize
});
documentList.value = response.data;
// PostgREST返回Content-Range头表示总数
const contentRange = response.headers['content-range'];
if (contentRange) {
const total = parseInt(contentRange.split('/')[1]);
pagination.total = total;
}
} catch (error) {
ElMessage.error('查询失败');
} finally {
loading.value = false;
}
};
// 搜索
const handleSearch = () => {
pagination.page = 1;
fetchList();
};
// 重置
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
handleSearch();
};
// 新建
const handleCreate = () => {
// 跳转到新建页面或打开对话框
console.log('新建文档');
};
// 编辑
const handleEdit = (row: Document) => {
console.log('编辑文档', row);
};
// 删除
const handleDelete = async (row: Document) => {
try {
await ElMessageBox.confirm('确定要删除该文档吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await deleteDocument(row.id);
ElMessage.success('删除成功');
fetchList();
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
// 初始化
onMounted(() => {
fetchList();
});
</script>
<style scoped>
.document-list {
padding: 20px;
}
.search-card {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
```
---
## 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
<template>
<!-- 管理员才能看到删除按钮 -->
<el-button
v-if="userStore.isAdmin"
type="danger"
@click="handleDelete"
>
删除
</el-button>
<!-- 只有文档所有者才能编辑 -->
<el-button
v-if="document.user_id === userStore.userInfo?.user_id"
type="primary"
@click="handleEdit"
>
编辑
</el-button>
</template>
```
**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);
// 使用
<el-button v-permission="'document:delete'" type="danger"></el-button>
```
---
## 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所有功能