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

40 KiB
Raw Permalink Blame History

前端完整对接文档 - RBAC与PostgREST

版本: v2.0 日期: 2025-11-17 目标: Vue 3 + TypeScript + Pinia + Element Plus 后端地址: http://172.16.0.55:8073


📋 目录

  1. 系统架构总览
  2. 认证流程详解
  3. RBAC动态路由对接
  4. PostgREST数据访问
  5. 完整代码示例
  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登录请求

// 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 Storestores/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存储在两个地方:

  1. Pinia Store的内存状态(userStore.token
  2. 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: 因为不同用户有不同权限,看到的菜单和可访问的页面不同。通过动态注册路由:

  1. 提高安全性(用户只能访问有权限的页面)
  2. 减少打包体积(按需加载组件)
  3. 灵活配置权限(后端控制)

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: 使用 limitoffset 参数:

// 第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: 使用 likeilike 操作符:

// 搜索标题包含"合同"的文档
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所有功能