5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。 6. 修改交叉评查的部分样式
40 KiB
前端完整对接文档 - RBAC与PostgREST
版本: v2.0
日期: 2025-11-17
目标: Vue 3 + TypeScript + Pinia + Element Plus
后端地址: http://172.16.0.55:8073
📋 目录
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
支持两种登录方式:
- OAuth登录 - IDaaS统一认证(推荐)
- 密码登录 - 用户名密码认证
2.1.1 OAuth登录请求
// 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 密码登录请求
// 密码登录请求格式
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 登录响应格式
// 统一响应格式
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)
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)
<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}
响应格式:
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[]; // 子路由
}
响应示例:
{
"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)
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)
<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)
<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)
// 基础查询
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)
// 创建单条记录
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)
// 更新记录(需要过滤条件)
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)
// 删除记录(需要过滤条件)
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)
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)
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 过滤:
// ❌ 错误:会查询所有用户的文档
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 过滤。
最佳实践
// 在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 完整示例:文档列表页面
<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存储在两个地方:
- Pinia Store的内存状态(
userStore.token) - localStorage持久化存储
// 保存Token
localStorage.setItem('token', access_token);
// 读取Token
const token = localStorage.getItem('token');
// 删除Token(登出)
localStorage.removeItem('token');
Q: Token过期如何处理?
A: 后端返回401状态码时,前端自动登出并跳转到登录页:
// 响应拦截器
if (error.response?.status === 401) {
ElMessage.error('登录已过期,请重新登录');
userStore.logout();
router.push('/login');
}
Q: 如何实现自动登录?
A: 在应用启动时从localStorage恢复登录状态:
// 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: 因为不同用户有不同权限,看到的菜单和可访问的页面不同。通过动态注册路由:
- 提高安全性(用户只能访问有权限的页面)
- 减少打包体积(按需加载组件)
- 灵活配置权限(后端控制)
Q: 动态路由何时加载?
A: 在用户登录成功后,通过路由守卫自动加载:
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 参数:
// 第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 返回总数:
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 操作符:
// 搜索标题包含"合同"的文档
GET /documents?title=like.*合同*
// 不区分大小写
GET /documents?title=ilike.*contract*
Q: 普通用户只能看到自己的数据吗?
A: 是的!后端已禁用自动数据隔离,前端必须手动添加 user_id 过滤条件:
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 字段判断:
const userStore = useUserStore();
// 方式1:直接判断
if (userStore.userInfo?.user_role === 'admin') {
console.log('管理员');
}
// 方式2:使用计算属性
if (userStore.isAdmin) {
console.log('管理员');
}
Q: 如何控制按钮显示/隐藏?
A: 使用 v-if 指令:
<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自定义指令:
// 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)
# API基础URL
VITE_API_BASE_URL=http://172.16.0.55:8073
# 应用端口
VITE_PORT=5173
# 是否开启Mock
VITE_USE_MOCK=false
7.2 生产环境配置(.env.production)
# 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所有功能