bfe39e45a9
5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。 6. 修改交叉评查的部分样式
1656 lines
40 KiB
Markdown
1656 lines
40 KiB
Markdown
# 前端完整对接文档 - 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<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所有功能
|