Files
2025-11-18 11:06:24 +08:00

1563 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DocAuditAI 前端对接文档
**版本**: v1.0
**最后更新**: 2025-11-17
**适用范围**: 前端开发人员
---
## 目录
1. [系统概述](#1-系统概述)
2. [认证与授权](#2-认证与授权)
3. [PostgREST API使用](#3-postgrest-api使用)
4. [RBAC权限系统](#4-rbac权限系统)
5. [数据范围过滤](#5-数据范围过滤)
6. [交叉评查权限](#6-交叉评查权限)
7. [错误处理](#7-错误处理)
8. [常用示例](#8-常用示例)
9. [最佳实践](#9-最佳实践)
10. [故障排查](#10-故障排查)
---
## 1. 系统概述
### 1.1 架构概览
DocAuditAI采用三层架构:
```
┌─────────────┐
│ 前端应用 │ Vue.js / React / Angular
└─────────────┘
↓ HTTPS
┌─────────────┐
│ FastAPI │ 认证、权限检查、业务逻辑
│ (端口8000) │
└─────────────┘
┌─────────────┐
│ PostgREST │ 直接数据库访问(经过RBAC过滤)
│ (端口3000) │
└─────────────┘
┌─────────────┐
│ PostgreSQL │ 数据存储
│ (端口5432) │
└─────────────┘
```
### 1.2 API端点
- **FastAPI主应用**: `http://localhost:8000/api/v1/`
- **PostgREST代理**: `http://localhost:8000/api/v1/postgrest/`
- **认证端点**: `http://localhost:8000/api/v1/auth/`
### 1.3 技术栈要求
**前端推荐技术栈**:
- **HTTP客户端**: Axios (推荐) 或 Fetch API
- **状态管理**: Vuex / Pinia (Vue) 或 Redux (React)
- **UI框架**: Element Plus / Ant Design / Material-UI
---
## 2. 认证与授权
### 2.1 JWT认证流程
DocAuditAI使用JWTJSON Web Token)进行用户认证。
#### 登录流程
```mermaid
sequenceDiagram
participant 前端
participant FastAPI
participant 数据库
前端->>FastAPI: POST /api/v1/auth/login {username, password}
FastAPI->>数据库: 验证用户凭据
数据库-->>FastAPI: 用户信息 + 权限
FastAPI-->>前端: {access_token, token_type, user_info}
前端->>前端: 存储access_token到localStorage
```
#### 登录示例
**请求**:
```javascript
// JavaScript/TypeScript
import axios from 'axios';
const API_BASE_URL = 'http://localhost:8000/api/v1';
async function login(username, password) {
try {
const response = await axios.post(`${API_BASE_URL}/auth/login`, {
username,
password
});
// 保存Token
localStorage.setItem('access_token', response.data.access_token);
localStorage.setItem('user_info', JSON.stringify(response.data.user_info));
return response.data;
} catch (error) {
console.error('登录失败:', error.response?.data || error.message);
throw error;
}
}
// 使用示例
login('user@example.com', 'password123')
.then(data => {
console.log('登录成功:', data.user_info);
})
.catch(err => {
console.error('登录失败');
});
```
**成功响应** (HTTP 200):
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"user_info": {
"user_id": 9,
"username": "云浮测试",
"ou_id": "yunfu002",
"ou_name": "云浮测试用户账户",
"roles": ["文档审查员", "普通用户"]
}
}
```
**失败响应** (HTTP 401):
```json
{
"detail": "用户名或密码错误"
}
```
### 2.2 请求认证
所有需要认证的API请求都必须在请求头中携带JWT Token。
#### Axios全局配置(推荐)
```javascript
// axios-instance.js
import axios from 'axios';
const API_BASE_URL = 'http://localhost:8000/api/v1';
// 创建Axios实例
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器:自动添加Token
apiClient.interceptors.request.use(
config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
// 响应拦截器:处理认证错误
apiClient.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// Token过期或无效,跳转到登录页
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
```
**使用示例**:
```javascript
import apiClient from './axios-instance';
// 查询文档列表(自动携带Token)
async function getDocuments() {
const response = await apiClient.get('/postgrest/documents?limit=10');
return response.data;
}
```
### 2.3 Token刷新
JWT Token有效期为24小时。Token过期后需要重新登录。
**检查Token是否过期**:
```javascript
function isTokenExpired() {
const token = localStorage.getItem('access_token');
if (!token) return true;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000; // 转换为毫秒
return Date.now() >= exp;
} catch (e) {
return true;
}
}
// 定期检查Token(可选)
setInterval(() => {
if (isTokenExpired()) {
alert('登录已过期,请重新登录');
window.location.href = '/login';
}
}, 60000); // 每分钟检查一次
```
### 2.4 退出登录
```javascript
async function logout() {
try {
// 调用后端登出接口(可选,如果后端有黑名单机制)
await apiClient.post('/auth/logout');
} catch (error) {
console.error('登出失败:', error);
} finally {
// 清除本地存储
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
window.location.href = '/login';
}
}
```
---
## 3. PostgREST API使用
### 3.1 PostgREST代理概述
PostgREST代理提供了对数据库表的直接RESTful访问,**自动集成RBAC权限检查和数据范围过滤**。
**基础URL**: `/api/v1/postgrest/{table_name}`
**支持的HTTP方法**:
- `GET`: 查询数据
- `POST`: 创建数据
- `PATCH`: 更新数据
- `DELETE`: 删除数据
### 3.2 查询操作 (GET)
#### 3.2.1 查询所有记录
```javascript
// 查询所有文档(自动应用数据范围过滤)
async function getAllDocuments() {
const response = await apiClient.get('/postgrest/documents');
return response.data;
}
// 响应示例
[
{
"id": 1936,
"title": "测试文档1",
"status": "active",
"ou_id": "yunfu002",
"user_id": 9,
"created_at": "2025-11-15T10:30:00Z"
},
{
"id": 1937,
"title": "测试文档2",
"status": "pending",
"ou_id": "yunfu002",
"user_id": 9,
"created_at": "2025-11-16T14:20:00Z"
}
]
```
#### 3.2.2 分页查询
PostgREST使用`limit``offset`参数实现分页。
```javascript
// 分页查询:每页10条,第2页
async function getDocumentsPaginated(page = 1, pageSize = 10) {
const offset = (page - 1) * pageSize;
const response = await apiClient.get('/postgrest/documents', {
params: {
limit: pageSize,
offset: offset
}
});
return response.data;
}
// 使用示例
const documents = await getDocumentsPaginated(2, 10); // 第2页,每页10条
```
#### 3.2.3 过滤查询
PostgREST支持丰富的过滤运算符:
| 运算符 | 说明 | 示例 |
|--------|------|------|
| `eq` | 等于 | `status=eq.active` |
| `neq` | 不等于 | `status=neq.deleted` |
| `gt` | 大于 | `created_at=gt.2025-01-01` |
| `gte` | 大于等于 | `score=gte.80` |
| `lt` | 小于 | `priority=lt.5` |
| `lte` | 小于等于 | `age=lte.30` |
| `like` | 模糊匹配 | `title=like.*测试*` |
| `ilike` | 不区分大小写模糊匹配 | `title=ilike.*TEST*` |
| `in` | 在列表中 | `status=in.(active,pending)` |
| `is` | 是null | `deleted_at=is.null` |
**示例**:
```javascript
// 查询状态为active的文档
async function getActiveDocuments() {
const response = await apiClient.get('/postgrest/documents', {
params: {
status: 'eq.active'
}
});
return response.data;
}
// 查询标题包含"合同"的文档
async function searchDocuments(keyword) {
const response = await apiClient.get('/postgrest/documents', {
params: {
title: `ilike.*${keyword}*`
}
});
return response.data;
}
// 多条件查询:状态为active且创建时间在2025年之后
async function getRecentActiveDocuments() {
const response = await apiClient.get('/postgrest/documents', {
params: {
status: 'eq.active',
created_at: 'gte.2025-01-01'
}
});
return response.data;
}
```
#### 3.2.4 排序
使用`order`参数指定排序字段和方向。
```javascript
// 按创建时间降序排列
async function getDocumentsSorted() {
const response = await apiClient.get('/postgrest/documents', {
params: {
order: 'created_at.desc'
}
});
return response.data;
}
// 多字段排序:先按状态升序,再按创建时间降序
async function getDocumentsMultiSort() {
const response = await apiClient.get('/postgrest/documents', {
params: {
order: 'status.asc,created_at.desc'
}
});
return response.data;
}
```
#### 3.2.5 字段选择
使用`select`参数指定返回的字段。
```javascript
// 只返回id、title、status字段
async function getDocumentsPartial() {
const response = await apiClient.get('/postgrest/documents', {
params: {
select: 'id,title,status'
}
});
return response.data;
}
// 响应示例
[
{
"id": 1936,
"title": "测试文档1",
"status": "active"
}
]
```
### 3.3 创建操作 (POST)
```javascript
// 创建新文档
async function createDocument(documentData) {
const response = await apiClient.post('/postgrest/documents', documentData);
return response.data;
}
// 使用示例
const newDocument = await createDocument({
title: '新文档',
content: '文档内容',
status: 'draft',
ou_id: 'yunfu002', // 后端会自动验证ou_id是否在用户权限范围内
user_id: 9
});
// 成功响应 (HTTP 201)
[
{
"id": 2001,
"title": "新文档",
"content": "文档内容",
"status": "draft",
"ou_id": "yunfu002",
"user_id": 9,
"created_at": "2025-11-17T10:00:00Z"
}
]
```
**重要提示**:
- 创建文档时,后端会自动验证`ou_id``user_id`是否符合用户的数据范围权限
- 如果尝试创建不在权限范围内的数据,会返回403 Forbidden
### 3.4 更新操作 (PATCH)
```javascript
// 更新文档状态
async function updateDocumentStatus(documentId, newStatus) {
const response = await apiClient.patch('/postgrest/documents',
{ status: newStatus },
{
params: {
id: `eq.${documentId}`
}
}
);
return response.data;
}
// 使用示例
const updated = await updateDocumentStatus(1936, 'reviewed');
// 成功响应 (HTTP 200)
[
{
"id": 1936,
"title": "测试文档1",
"status": "reviewed",
"updated_at": "2025-11-17T11:00:00Z"
}
]
```
**重要提示**:
- 更新操作会自动应用数据范围过滤,用户只能更新自己权限范围内的数据
- 对于交叉评查文档,如果用户参与了该文档的交叉评查任务,即使文档不在常规数据范围内,也可以更新
### 3.5 删除操作 (DELETE)
```javascript
// 删除文档
async function deleteDocument(documentId) {
const response = await apiClient.delete('/postgrest/documents', {
params: {
id: `eq.${documentId}`
}
});
return response.status === 204; // 成功删除返回204 No Content
}
// 使用示例
const deleted = await deleteDocument(1936);
if (deleted) {
console.log('文档删除成功');
}
```
### 3.6 完整示例:文档管理
```javascript
// document-service.js
import apiClient from './axios-instance';
class DocumentService {
// 查询文档列表
async getDocuments(filters = {}) {
const params = {
limit: filters.pageSize || 10,
offset: ((filters.page || 1) - 1) * (filters.pageSize || 10),
...filters.where
};
if (filters.orderBy) {
params.order = filters.orderBy;
}
const response = await apiClient.get('/postgrest/documents', { params });
return response.data;
}
// 获取单个文档
async getDocumentById(id) {
const response = await apiClient.get('/postgrest/documents', {
params: { id: `eq.${id}` }
});
return response.data[0]; // PostgREST返回数组,取第一个
}
// 创建文档
async createDocument(data) {
const response = await apiClient.post('/postgrest/documents', data);
return response.data[0];
}
// 更新文档
async updateDocument(id, data) {
const response = await apiClient.patch(
'/postgrest/documents',
data,
{ params: { id: `eq.${id}` } }
);
return response.data[0];
}
// 删除文档
async deleteDocument(id) {
await apiClient.delete('/postgrest/documents', {
params: { id: `eq.${id}` }
});
return true;
}
}
export default new DocumentService();
```
**使用示例**:
```javascript
import DocumentService from './document-service';
// 1. 查询文档列表(分页、过滤、排序)
const documents = await DocumentService.getDocuments({
page: 1,
pageSize: 10,
where: {
status: 'eq.active',
title: 'ilike.*合同*'
},
orderBy: 'created_at.desc'
});
// 2. 获取单个文档
const document = await DocumentService.getDocumentById(1936);
// 3. 创建文档
const newDoc = await DocumentService.createDocument({
title: '采购合同',
content: '合同内容...',
status: 'draft'
});
// 4. 更新文档
const updatedDoc = await DocumentService.updateDocument(1936, {
status: 'reviewed'
});
// 5. 删除文档
await DocumentService.deleteDocument(1936);
```
---
## 4. RBAC权限系统
### 4.1 权限模型
DocAuditAI采用**RBAC(基于角色的访问控制)**模型,支持:
- **用户-角色关联**: 一个用户可以有多个角色
- **角色-权限关联**: 一个角色可以有多个权限
- **用户-权限关联**: 用户可以直接拥有权限(绕过角色)
- **数据范围控制**: 细粒度的数据访问权限(ALL/DEPT/DEPT_AND_SUB/SELF/CUSTOM
#### 权限键格式
权限键格式为:`{module}:{resource}:{action}`
**示例**:
- `document:document:view` - 查看文档
- `document:document:create` - 创建文档
- `document:document:update` - 更新文档
- `document:document:delete` - 删除文档
- `crossreview:task:view` - 查看交叉评查任务
### 4.2 自动权限检查
**所有通过PostgREST代理的请求都会自动进行权限检查**,前端无需手动调用权限检查接口。
**权限检查流程**:
1. 用户发起请求(携带JWT Token)
2. 后端解析Token,提取用户ID和角色
3. 根据表名和HTTP方法映射到权限键
4. 检查用户是否拥有该权限
5. 如果有权限,继续处理请求;否则返回403 Forbidden
**示例**:
```javascript
// GET /api/v1/postgrest/documents
// 后端自动检查权限: document:document:view
// POST /api/v1/postgrest/documents
// 后端自动检查权限: document:document:create
// PATCH /api/v1/postgrest/documents?id=eq.1936
// 后端自动检查权限: document:document:update
// DELETE /api/v1/postgrest/documents?id=eq.1936
// 后端自动检查权限: document:document:delete
```
### 4.3 前端权限控制
虽然后端会自动检查权限,但前端也应该根据用户权限**隐藏或禁用**无权操作的按钮和菜单。
#### 4.3.1 获取当前用户权限
```javascript
// 从Token中解析用户信息(包含角色)
function getUserInfo() {
const userInfoStr = localStorage.getItem('user_info');
if (!userInfoStr) return null;
return JSON.parse(userInfoStr);
}
// 检查用户是否有特定角色
function hasRole(roleName) {
const userInfo = getUserInfo();
return userInfo?.roles?.includes(roleName) || false;
}
// 使用示例
if (hasRole('系统管理员')) {
// 显示管理员菜单
}
```
#### 4.3.2 权限指令(Vue示例)
```vue
<!-- Vue自定义指令v-permission -->
<template>
<button v-permission="'document:document:delete'" @click="deleteDoc">
删除文档
</button>
</template>
<script>
// permission-directive.js
export default {
mounted(el, binding) {
const permission = binding.value;
const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}');
// 简化版:根据角色判断(实际应该调用后端接口获取完整权限列表)
const hasPermission = checkPermission(userInfo, permission);
if (!hasPermission) {
el.style.display = 'none'; // 隐藏按钮
// 或者禁用按钮
// el.disabled = true;
}
}
};
function checkPermission(userInfo, permission) {
// 简化逻辑:管理员拥有所有权限
if (userInfo.roles?.includes('系统管理员')) return true;
// 实际应该维护一个角色-权限映射表或调用后端接口
const rolePermissions = {
'文档审查员': ['document:document:view', 'document:document:update'],
'文档管理员': ['document:document:view', 'document:document:create', 'document:document:update', 'document:document:delete']
};
return userInfo.roles?.some(role => rolePermissions[role]?.includes(permission)) || false;
}
</script>
```
**推荐做法**:
1. 登录时从后端获取用户的完整权限列表(包括通过角色继承的权限和直接分配的权限)
2. 将权限列表存储到Vuex/Pinia/Redux中
3. 前端根据权限列表控制UI元素的显示/隐藏
---
## 5. 数据范围过滤
### 5.1 数据范围类型
DocAuditAI支持5种数据范围类型:
| 数据范围 | 说明 | 过滤逻辑 |
|---------|------|---------|
| **ALL** | 全部数据 | 无过滤,可查看所有数据 |
| **DEPT** | 本部门数据 | `ou_id = 用户的ou_id` |
| **DEPT_AND_SUB** | 本部门及下级部门数据 | `ou_id IN (用户的ou_id_tree)` |
| **SELF** | 本人数据 | `user_id = 用户ID` |
| **CUSTOM** | 自定义规则 | 根据自定义SQL表达式过滤 |
### 5.2 自动数据范围过滤
**所有通过PostgREST代理的查询请求都会自动应用数据范围过滤**,前端无需关心过滤逻辑。
**示例**:
```javascript
// 用户A(数据范围SELFuser_id=9
// 请求: GET /api/v1/postgrest/documents
// 后端自动添加过滤: user_id=eq.9
// 用户B(数据范围DEPTou_id=yunfu002
// 请求: GET /api/v1/postgrest/documents
// 后端自动添加过滤: ou_id=eq.yunfu002
// 用户C(数据范围ALL
// 请求: GET /api/v1/postgrest/documents
// 后端无过滤,返回所有文档
```
### 5.3 前端注意事项
1. **不要尝试绕过数据范围过滤**
后端会强制覆盖前端提供的`ou_id``user_id`参数,尝试绕过会被拒绝或忽略。
```javascript
// ❌ 错误示例:尝试访问其他部门数据
const response = await apiClient.get('/postgrest/documents', {
params: {
ou_id: 'eq.guangzhou001' // 后端会覆盖为用户自己的ou_id
}
});
// ✅ 正确示例:正常查询,后端自动应用数据范围
const response = await apiClient.get('/postgrest/documents');
```
2. **数据范围对不同操作的影响**
- **查询(GET)**: 自动过滤,只返回权限范围内的数据
- **创建(POST**: 验证ou_id/user_id是否在权限范围内
- **更新(PATCH)**: 只能更新权限范围内的数据
- **删除(DELETE)**: 只能删除权限范围内的数据
---
## 6. 交叉评查权限
### 6.1 交叉评查权限概述
交叉评查允许用户**跨部门访问**特定文档,即使这些文档不在其常规数据范围内。
**应用场景**:
- 用户A(云浮部门)参与了一个交叉评查任务,该任务包含来自梅州部门的文档
- 用户A可以查看和评审这些梅州文档,尽管其常规数据范围是SELF(只能看自己的)
### 6.2 交叉评查权限逻辑
对于配置了`special_handling='cross_review_mixed'`的表(如`documents`),后端会:
1. **GET请求**: 扩展访问范围(常规数据范围 **OR** 交叉评查文档)
```
常规过滤: user_id=eq.9
交叉评查扩展: id.in.(1936,1937)
最终过滤: or=(id.in.(1936,1937),user_id.eq.9)
```
2. **PATCH/DELETE请求**: 检查目标文档是否在交叉评查范围
- 如果是交叉评查文档 → 移除常规数据范围限制,允许操作
- 如果不是 → 保持常规数据范围限制
### 6.3 前端使用示例
前端无需特殊处理,后端会自动处理交叉评查权限。
```javascript
// 场景:用户9(数据范围SELF)参与了任务183的交叉评查
// 任务183包含文档[1936, 1937](来自其他用户)
// 1. 查询文档列表
const documents = await apiClient.get('/postgrest/documents');
// 返回:
// - 用户自己的文档(user_id=9
// - 交叉评查文档[1936, 1937]
// 2. 更新交叉评查文档
const updated = await apiClient.patch(
'/postgrest/documents',
{ status: 'reviewed' },
{ params: { id: 'eq.1936' } }
);
// 成功:后端检测到1936是交叉评查文档,允许更新
// 3. 尝试更新其他用户的非交叉评查文档
try {
await apiClient.patch(
'/postgrest/documents',
{ status: 'reviewed' },
{ params: { id: 'eq.9999' } } // 不在交叉评查范围
);
} catch (error) {
console.error('无权更新此文档'); // 403 Forbidden
}
```
### 6.4 获取交叉评查任务
```javascript
// 查询当前用户的交叉评查任务
async function getCrossReviewTasks() {
const response = await apiClient.get('/postgrest/cross_examination_tasks');
return response.data;
}
// 响应示例
[
{
"id": 183,
"task_name": "2025年第一季度交叉评查",
"status": "in_progress",
"created_by": 5,
"user_ids": [9, 10, 11], // 参与用户
"created_at": "2025-11-10T09:00:00Z"
}
]
```
---
## 7. 错误处理
### 7.1 HTTP状态码
| 状态码 | 说明 | 处理建议 |
|--------|------|---------|
| **200 OK** | 请求成功 | 正常处理数据 |
| **201 Created** | 创建成功 | 显示成功消息 |
| **204 No Content** | 删除成功 | 显示成功消息 |
| **400 Bad Request** | 请求参数错误 | 检查请求参数格式 |
| **401 Unauthorized** | 未认证或Token无效 | 跳转到登录页 |
| **403 Forbidden** | 无权限 | 显示"无权限"提示 |
| **404 Not Found** | 资源不存在 | 显示"资源不存在"提示 |
| **500 Internal Server Error** | 服务器错误 | 显示"服务器错误"提示 |
### 7.2 错误响应格式
**标准错误响应**:
```json
{
"detail": "错误描述"
}
```
**详细错误响应**(包含更多上下文):
```json
{
"detail": "权限不足",
"error_code": "PERMISSION_DENIED",
"permission_required": "document:document:delete",
"user_id": 9
}
```
### 7.3 全局错误处理
```javascript
// axios-instance.js
apiClient.interceptors.response.use(
response => response,
error => {
const status = error.response?.status;
const detail = error.response?.data?.detail || '未知错误';
switch (status) {
case 400:
// 请求参数错误
console.error('请求参数错误:', detail);
alert(`请求参数错误: ${detail}`);
break;
case 401:
// 未认证或Token无效
console.error('认证失败,跳转到登录页');
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
window.location.href = '/login';
break;
case 403:
// 无权限
console.error('权限不足:', detail);
alert(`权限不足: ${detail}`);
break;
case 404:
// 资源不存在
console.error('资源不存在:', detail);
alert(`资源不存在: ${detail}`);
break;
case 500:
// 服务器错误
console.error('服务器错误:', detail);
alert(`服务器错误,请稍后重试`);
break;
default:
console.error('未知错误:', error);
alert(`请求失败,请稍后重试`);
}
return Promise.reject(error);
}
);
```
### 7.4 特定错误处理
```javascript
// 创建文档时处理权限错误
async function createDocumentWithErrorHandling(data) {
try {
const response = await apiClient.post('/postgrest/documents', data);
alert('文档创建成功');
return response.data[0];
} catch (error) {
if (error.response?.status === 403) {
const detail = error.response.data?.detail || '权限不足';
if (detail.includes('数据范围')) {
alert('您无权在此组织单位创建文档');
} else {
alert('您没有创建文档的权限');
}
} else {
alert('创建文档失败,请重试');
}
throw error;
}
}
```
---
## 8. 常用示例
### 8.1 文档列表页面
```vue
<template>
<div class="document-list">
<h1>文档列表</h1>
<!-- 搜索框 -->
<div class="search-bar">
<input
v-model="searchKeyword"
@input="onSearch"
placeholder="搜索文档标题"
/>
<select v-model="searchStatus" @change="onSearch">
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="active">活跃</option>
<option value="reviewed">已评查</option>
</select>
</div>
<!-- 文档列表 -->
<table>
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="doc in documents" :key="doc.id">
<td>{{ doc.id }}</td>
<td>{{ doc.title }}</td>
<td>{{ doc.status }}</td>
<td>{{ formatDate(doc.created_at) }}</td>
<td>
<button @click="viewDocument(doc.id)">查看</button>
<button
v-permission="'document:document:update'"
@click="editDocument(doc.id)"
>
编辑
</button>
<button
v-permission="'document:document:delete'"
@click="deleteDocument(doc.id)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div class="pagination">
<button @click="prevPage" :disabled="currentPage === 1">上一页</button>
<span>第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
<button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import apiClient from '@/services/axios-instance';
export default {
setup() {
const documents = ref([]);
const currentPage = ref(1);
const pageSize = ref(10);
const totalPages = ref(1);
const searchKeyword = ref('');
const searchStatus = ref('');
// 加载文档列表
async function loadDocuments() {
try {
const params = {
limit: pageSize.value,
offset: (currentPage.value - 1) * pageSize.value,
order: 'created_at.desc'
};
if (searchKeyword.value) {
params.title = `ilike.*${searchKeyword.value}*`;
}
if (searchStatus.value) {
params.status = `eq.${searchStatus.value}`;
}
const response = await apiClient.get('/postgrest/documents', { params });
documents.value = response.data;
// 计算总页数(PostgREST返回Content-Range头)
const contentRange = response.headers['content-range'];
if (contentRange) {
const total = parseInt(contentRange.split('/')[1]);
totalPages.value = Math.ceil(total / pageSize.value);
}
} catch (error) {
console.error('加载文档失败:', error);
}
}
// 搜索
function onSearch() {
currentPage.value = 1;
loadDocuments();
}
// 分页
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--;
loadDocuments();
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++;
loadDocuments();
}
}
// 删除文档
async function deleteDocument(id) {
if (!confirm('确定要删除此文档吗?')) return;
try {
await apiClient.delete('/postgrest/documents', {
params: { id: `eq.${id}` }
});
alert('删除成功');
loadDocuments();
} catch (error) {
console.error('删除失败:', error);
}
}
// 格式化日期
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString('zh-CN');
}
onMounted(() => {
loadDocuments();
});
return {
documents,
currentPage,
totalPages,
searchKeyword,
searchStatus,
onSearch,
prevPage,
nextPage,
deleteDocument,
formatDate
};
}
};
</script>
```
### 8.2 文档详情页面
```vue
<template>
<div class="document-detail">
<h1>{{ document.title }}</h1>
<div class="meta">
<p>状态: {{ document.status }}</p>
<p>创建时间: {{ formatDate(document.created_at) }}</p>
<p>更新时间: {{ formatDate(document.updated_at) }}</p>
</div>
<div class="content">
{{ document.content }}
</div>
<div class="actions">
<button
v-permission="'document:document:update'"
@click="editDocument"
>
编辑
</button>
<button
v-permission="'document:document:update'"
@click="changeStatus('reviewed')"
>
标记为已评查
</button>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import apiClient from '@/services/axios-instance';
export default {
setup() {
const route = useRoute();
const document = ref({});
// 加载文档详情
async function loadDocument() {
try {
const id = route.params.id;
const response = await apiClient.get('/postgrest/documents', {
params: { id: `eq.${id}` }
});
document.value = response.data[0];
} catch (error) {
console.error('加载文档失败:', error);
alert('加载文档失败');
}
}
// 更改状态
async function changeStatus(newStatus) {
try {
await apiClient.patch(
'/postgrest/documents',
{ status: newStatus },
{ params: { id: `eq.${document.value.id}` } }
);
alert('状态更新成功');
loadDocument();
} catch (error) {
console.error('更新状态失败:', error);
}
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString('zh-CN');
}
onMounted(() => {
loadDocument();
});
return {
document,
changeStatus,
formatDate
};
}
};
</script>
```
---
## 9. 最佳实践
### 9.1 安全最佳实践
1. **始终使用HTTPS**
生产环境必须使用HTTPS传输,防止Token被窃取。
2. **安全存储Token**
- 使用`localStorage`或`sessionStorage`存储Token
- 不要将Token存储在Cookie中(避免CSRF攻击)
- 不要将Token暴露在URL参数中
3. **Token过期处理**
- 定期检查Token是否过期
- Token过期后立即跳转到登录页
- 提供Token刷新机制(如果后端支持)
4. **不要绕过前端权限检查**
- 即使前端隐藏了按钮,用户仍可能通过开发者工具发起请求
- 后端会强制执行权限检查,前端绕过无效
### 9.2 性能最佳实践
1. **分页查询**
始终使用分页,避免一次性加载大量数据。
```javascript
// ✅ 好的做法
const response = await apiClient.get('/postgrest/documents', {
params: { limit: 10, offset: 0 }
});
// ❌ 不好的做法
const response = await apiClient.get('/postgrest/documents'); // 返回所有数据
```
2. **字段选择**
只查询需要的字段,减少数据传输量。
```javascript
// ✅ 好的做法
const response = await apiClient.get('/postgrest/documents', {
params: { select: 'id,title,status' }
});
// ❌ 不好的做法
const response = await apiClient.get('/postgrest/documents'); // 返回所有字段
```
3. **缓存数据**
对于不经常变化的数据(如字典表、配置表),使用前端缓存。
```javascript
// 缓存字典数据
let documentTypesCache = null;
async function getDocumentTypes() {
if (documentTypesCache) {
return documentTypesCache;
}
const response = await apiClient.get('/postgrest/document_types');
documentTypesCache = response.data;
// 5分钟后过期
setTimeout(() => {
documentTypesCache = null;
}, 5 * 60 * 1000);
return documentTypesCache;
}
```
4. **批量操作**
尽量减少请求次数,使用批量操作。
```javascript
// ❌ 不好的做法:逐个删除
for (const id of [1, 2, 3]) {
await apiClient.delete('/postgrest/documents', {
params: { id: `eq.${id}` }
});
}
// ✅ 好的做法:批量删除
await apiClient.delete('/postgrest/documents', {
params: { id: `in.(1,2,3)` }
});
```
### 9.3 用户体验最佳实践
1. **加载状态**
显示加载指示器,提升用户体验。
```vue
<template>
<div v-if="loading">加载中...</div>
<div v-else>
<!-- 内容 -->
</div>
</template>
<script>
export default {
data() {
return {
loading: false
};
},
methods: {
async loadData() {
this.loading = true;
try {
// 加载数据
} finally {
this.loading = false;
}
}
}
};
</script>
```
2. **错误提示**
友好的错误提示,避免显示技术细节。
```javascript
// ❌ 不好的做法
alert(error.message); // "Cannot read property 'data' of undefined"
// ✅ 好的做法
alert('加载数据失败,请稍后重试');
```
3. **操作确认**
对于删除等危险操作,提供确认提示。
```javascript
async function deleteDocument(id) {
if (!confirm('确定要删除此文档吗?此操作不可撤销。')) {
return;
}
try {
await apiClient.delete('/postgrest/documents', {
params: { id: `eq.${id}` }
});
alert('删除成功');
} catch (error) {
alert('删除失败');
}
}
```
---
## 10. 故障排查
### 10.1 常见问题
#### 问题1401 Unauthorized - Token无效
**症状**: 所有API请求返回401错误。
**原因**:
- Token已过期
- Token格式错误
- Token未正确携带在请求头中
**解决方法**:
1. 检查Token是否存在:`localStorage.getItem('access_token')`
2. 检查Token格式是否正确(应为`Bearer {token}`
3. 检查Token是否过期
4. 重新登录获取新Token
#### 问题2403 Forbidden - 权限不足
**症状**: 某些API请求返回403错误。
**原因**:
- 用户没有该操作的权限
- 尝试访问不在数据范围内的数据
**解决方法**:
1. 确认用户是否有对应的权限(检查用户角色)
2. 确认数据是否在用户的数据范围内
3. 联系管理员分配权限
#### 问题3CORS错误
**症状**: 浏览器控制台显示CORS错误。
**原因**:
- 前端域名未在后端CORS白名单中
**解决方法**:
1. 联系后端开发人员将前端域名添加到CORS白名单
2. 开发环境可以配置代理绕过CORS
**Vue.js开发环境代理配置**:
```javascript
// vue.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
};
```
#### 问题4:数据未按预期过滤
**症状**: 查询返回了不应该看到的数据。
**原因**:
- 后端数据范围配置错误
- 前端缓存了旧数据
**解决方法**:
1. 清除浏览器缓存和localStorage
2. 检查用户的数据范围配置是否正确
3. 联系后端开发人员检查RBAC配置
### 10.2 调试技巧
#### 1. 查看请求详情
使用浏览器开发者工具查看请求详情:
1. 打开开发者工具(F12
2. 切换到Network标签
3. 发起请求
4. 点击请求查看Headers、Payload、Response
#### 2. 查看Token内容
```javascript
// 解码JWT Token
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
console.log('Token内容:', payload);
return payload;
} catch (e) {
console.error('Token解码失败:', e);
return null;
}
}
// 使用示例
const token = localStorage.getItem('access_token');
decodeToken(token);
```
#### 3. 启用详细日志
```javascript
// axios-instance.js
apiClient.interceptors.request.use(config => {
console.log('[Request]', config.method.toUpperCase(), config.url, config.params);
return config;
});
apiClient.interceptors.response.use(
response => {
console.log('[Response]', response.status, response.data);
return response;
},
error => {
console.error('[Error]', error.response?.status, error.response?.data);
return Promise.reject(error);
}
);
```
---
## 附录
### A. 完整API端点列表
详见:[API端点列表文档](./前端对接文档-API端点列表.md)
### B. PostgREST过滤运算符完整列表
详见:[PostgREST查询参考](./前端对接文档-PostgREST查询参考.md)
### C. 权限列表
详见:[权限列表文档](./前端对接文档-权限列表.md)
---
## 联系支持
如有问题或建议,请联系:
- **技术支持**: support@docauditai.com
- **开发团队**: dev@docauditai.com
---
**文档版本**: v1.0
**最后更新**: 2025-11-17
**维护者**: Claude Code