5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。 6. 修改交叉评查的部分样式
37 KiB
DocAuditAI 前端对接文档
版本: v1.0 最后更新: 2025-11-17 适用范围: 前端开发人员
目录
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使用JWT(JSON Web Token)进行用户认证。
登录流程
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/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):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"user_info": {
"user_id": 9,
"username": "云浮测试",
"ou_id": "yunfu002",
"ou_name": "云浮测试用户账户",
"roles": ["文档审查员", "普通用户"]
}
}
失败响应 (HTTP 401):
{
"detail": "用户名或密码错误"
}
2.2 请求认证
所有需要认证的API请求都必须在请求头中携带JWT Token。
Axios全局配置(推荐)
// 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;
使用示例:
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是否过期:
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 退出登录
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 查询所有记录
// 查询所有文档(自动应用数据范围过滤)
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参数实现分页。
// 分页查询:每页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 |
示例:
// 查询状态为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参数指定排序字段和方向。
// 按创建时间降序排列
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参数指定返回的字段。
// 只返回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)
// 创建新文档
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)
// 更新文档状态
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)
// 删除文档
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 完整示例:文档管理
// 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();
使用示例:
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代理的请求都会自动进行权限检查,前端无需手动调用权限检查接口。
权限检查流程:
- 用户发起请求(携带JWT Token)
- 后端解析Token,提取用户ID和角色
- 根据表名和HTTP方法映射到权限键
- 检查用户是否拥有该权限
- 如果有权限,继续处理请求;否则返回403 Forbidden
示例:
// 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 获取当前用户权限
// 从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自定义指令: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>
推荐做法:
- 登录时从后端获取用户的完整权限列表(包括通过角色继承的权限和直接分配的权限)
- 将权限列表存储到Vuex/Pinia/Redux中
- 前端根据权限列表控制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代理的查询请求都会自动应用数据范围过滤,前端无需关心过滤逻辑。
示例:
// 用户A(数据范围SELF,user_id=9)
// 请求: GET /api/v1/postgrest/documents
// 后端自动添加过滤: user_id=eq.9
// 用户B(数据范围DEPT,ou_id=yunfu002)
// 请求: GET /api/v1/postgrest/documents
// 后端自动添加过滤: ou_id=eq.yunfu002
// 用户C(数据范围ALL)
// 请求: GET /api/v1/postgrest/documents
// 后端无过滤,返回所有文档
5.3 前端注意事项
-
不要尝试绕过数据范围过滤 后端会强制覆盖前端提供的
ou_id或user_id参数,尝试绕过会被拒绝或忽略。// ❌ 错误示例:尝试访问其他部门数据 const response = await apiClient.get('/postgrest/documents', { params: { ou_id: 'eq.guangzhou001' // 后端会覆盖为用户自己的ou_id } }); // ✅ 正确示例:正常查询,后端自动应用数据范围 const response = await apiClient.get('/postgrest/documents'); -
数据范围对不同操作的影响
- 查询(GET): 自动过滤,只返回权限范围内的数据
- 创建(POST): 验证ou_id/user_id是否在权限范围内
- 更新(PATCH): 只能更新权限范围内的数据
- 删除(DELETE): 只能删除权限范围内的数据
6. 交叉评查权限
6.1 交叉评查权限概述
交叉评查允许用户跨部门访问特定文档,即使这些文档不在其常规数据范围内。
应用场景:
- 用户A(云浮部门)参与了一个交叉评查任务,该任务包含来自梅州部门的文档
- 用户A可以查看和评审这些梅州文档,尽管其常规数据范围是SELF(只能看自己的)
6.2 交叉评查权限逻辑
对于配置了special_handling='cross_review_mixed'的表(如documents),后端会:
-
GET请求: 扩展访问范围(常规数据范围 OR 交叉评查文档)
常规过滤: user_id=eq.9 交叉评查扩展: id.in.(1936,1937) 最终过滤: or=(id.in.(1936,1937),user_id.eq.9) -
PATCH/DELETE请求: 检查目标文档是否在交叉评查范围
- 如果是交叉评查文档 → 移除常规数据范围限制,允许操作
- 如果不是 → 保持常规数据范围限制
6.3 前端使用示例
前端无需特殊处理,后端会自动处理交叉评查权限。
// 场景:用户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 获取交叉评查任务
// 查询当前用户的交叉评查任务
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 错误响应格式
标准错误响应:
{
"detail": "错误描述"
}
详细错误响应(包含更多上下文):
{
"detail": "权限不足",
"error_code": "PERMISSION_DENIED",
"permission_required": "document:document:delete",
"user_id": 9
}
7.3 全局错误处理
// 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 特定错误处理
// 创建文档时处理权限错误
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 文档列表页面
<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 文档详情页面
<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 安全最佳实践
-
始终使用HTTPS 生产环境必须使用HTTPS传输,防止Token被窃取。
-
安全存储Token
- 使用
localStorage或sessionStorage存储Token - 不要将Token存储在Cookie中(避免CSRF攻击)
- 不要将Token暴露在URL参数中
- 使用
-
Token过期处理
- 定期检查Token是否过期
- Token过期后立即跳转到登录页
- 提供Token刷新机制(如果后端支持)
-
不要绕过前端权限检查
- 即使前端隐藏了按钮,用户仍可能通过开发者工具发起请求
- 后端会强制执行权限检查,前端绕过无效
9.2 性能最佳实践
-
分页查询 始终使用分页,避免一次性加载大量数据。
// ✅ 好的做法 const response = await apiClient.get('/postgrest/documents', { params: { limit: 10, offset: 0 } }); // ❌ 不好的做法 const response = await apiClient.get('/postgrest/documents'); // 返回所有数据 -
字段选择 只查询需要的字段,减少数据传输量。
// ✅ 好的做法 const response = await apiClient.get('/postgrest/documents', { params: { select: 'id,title,status' } }); // ❌ 不好的做法 const response = await apiClient.get('/postgrest/documents'); // 返回所有字段 -
缓存数据 对于不经常变化的数据(如字典表、配置表),使用前端缓存。
// 缓存字典数据 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; } -
批量操作 尽量减少请求次数,使用批量操作。
// ❌ 不好的做法:逐个删除 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 用户体验最佳实践
-
加载状态 显示加载指示器,提升用户体验。
<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> -
错误提示 友好的错误提示,避免显示技术细节。
// ❌ 不好的做法 alert(error.message); // "Cannot read property 'data' of undefined" // ✅ 好的做法 alert('加载数据失败,请稍后重试'); -
操作确认 对于删除等危险操作,提供确认提示。
async function deleteDocument(id) { if (!confirm('确定要删除此文档吗?此操作不可撤销。')) { return; } try { await apiClient.delete('/postgrest/documents', { params: { id: `eq.${id}` } }); alert('删除成功'); } catch (error) { alert('删除失败'); } }
10. 故障排查
10.1 常见问题
问题1:401 Unauthorized - Token无效
症状: 所有API请求返回401错误。
原因:
- Token已过期
- Token格式错误
- Token未正确携带在请求头中
解决方法:
- 检查Token是否存在:
localStorage.getItem('access_token') - 检查Token格式是否正确(应为
Bearer {token}) - 检查Token是否过期
- 重新登录获取新Token
问题2:403 Forbidden - 权限不足
症状: 某些API请求返回403错误。
原因:
- 用户没有该操作的权限
- 尝试访问不在数据范围内的数据
解决方法:
- 确认用户是否有对应的权限(检查用户角色)
- 确认数据是否在用户的数据范围内
- 联系管理员分配权限
问题3:CORS错误
症状: 浏览器控制台显示CORS错误。
原因:
- 前端域名未在后端CORS白名单中
解决方法:
- 联系后端开发人员将前端域名添加到CORS白名单
- 开发环境可以配置代理绕过CORS
Vue.js开发环境代理配置:
// vue.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
};
问题4:数据未按预期过滤
症状: 查询返回了不应该看到的数据。
原因:
- 后端数据范围配置错误
- 前端缓存了旧数据
解决方法:
- 清除浏览器缓存和localStorage
- 检查用户的数据范围配置是否正确
- 联系后端开发人员检查RBAC配置
10.2 调试技巧
1. 查看请求详情
使用浏览器开发者工具查看请求详情:
- 打开开发者工具(F12)
- 切换到Network标签
- 发起请求
- 点击请求查看Headers、Payload、Response
2. 查看Token内容
// 解码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. 启用详细日志
// 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端点列表文档
B. PostgREST过滤运算符完整列表
C. 权限列表
详见:权限列表文档
联系支持
如有问题或建议,请联系:
- 技术支持: support@docauditai.com
- 开发团队: dev@docauditai.com
文档版本: v1.0 最后更新: 2025-11-17 维护者: Claude Code