Files
leaudit-platform-frontend/app/api/axios-client.ts
T
TanWenyan 689ef6bc3d fix: 修复角色权限管理模块的API认证和数据加载问题
主要修复:
1. 修复所有RBAC API函数使用axios-client(自动添加JWT token)
   - getRoles, createRole, updateRole, deleteRole 从rbacFetch切换到axios-client
   - 解决401未授权导致的数据加载失败问题

2. 修复用户ID字段不匹配问题
   - getAllUsers函数使用user_id字段(兼容user.user_id || user.id)
   - 确保角色分配时使用正确的用户ID

3. 修复路由ID不匹配问题
   - getRoutes函数改用真实后端API(GET /rbac/user/routes)
   - 解决前端Mock路由ID与数据库不一致导致的400错误

4. 增强axios-client成功响应识别
   - 支持code=200作为成功状态(原本只支持code=0)
   - 兼容不同后端API的响应格式

5. 实现用户单角色限制功能
   - 添加getUserRoles API函数
   - 分配角色前检查用户现有角色
   - 在用户列表中显示当前角色标签

6. 改进创建角色的表单验证
   - role_key必须以字母开头(正则:^[a-z][a-z0-9_]*$)
   - 添加实时验证提示
   - 更新提示文案说明规则

7. 添加删除操作的安全确认机制
   - 删除角色/移除用户角色前显示确认模态框
   - 3秒倒计时后才能确认删除
   - 成功删除后自动刷新数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 18:03:57 +08:00

586 lines
19 KiB
TypeScript
Raw 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.
import axios, { AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';
import { mockData, type MockApiResponse } from './mock';
import { API_BASE_URL, DOCUMENT_URL } from '../config/api-config';
/**
* API响应类型
*/
export type ApiResponse<T> = {
data?: T;
error?: string;
status: number;
headers?: Record<string, string>;
};
export type QueryParams = Record<string, string | number | boolean | undefined | number[] | string[]>;
// 获取 API 基础 URL (从配置文件导入)
// const API_BASE_URL = 'http://172.16.0.58:8008';
// const API_BASE_URL = 'http://nas.7bm.co:3000';
// const API_BASE_URL = 'http://172.18.0.100:3000';
// const API_BASE_URL = 'http://172.16.0.119:9000/admin';
// 调试:打印当前API_BASE_URL的值
console.log('🔍 axios-client.ts - API_BASE_URL:', API_BASE_URL);
// 文档URL前缀 (从配置文件导入)
// export const DOCUMENT_URL = 'http://nas.7bm.co:9000/docauditai/';
export { DOCUMENT_URL };
// 是否使用模拟数据(开发环境使用)
const USE_MOCK_DATA = false; // 设置为true使用模拟数据,避免API连接问题
// 设置超时时间(毫秒)
const DEFAULT_TIMEOUT = 30000; // 增加到30秒
// 创建 axios 实例
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: DEFAULT_TIMEOUT, // 增加超时时间
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
// 请求白名单 - 这些接口不需要添加 Authorization 头
const AUTH_WHITELIST = [
'/auth/login',
'/auth/refresh',
'/auth/register',
'/oauth/token',
'/oauth/userinfo'
];
// 错误容忍白名单 - 这些接口即使返回 401/403 也不触发强制登出
const ERROR_TOLERANT_WHITELIST = [
'/admin/statistics/top-error-points',
'/admin/statistics/top-risk-users'
];
/**
* 检查请求URL是否在白名单中
*/
function isInAuthWhitelist(url?: string): boolean {
if (!url) return false;
return AUTH_WHITELIST.some(path => url.includes(path));
}
/**
* 检查请求URL是否在错误容忍白名单中
*/
function isInErrorTolerantWhitelist(url?: string): boolean {
if (!url) return false;
return ERROR_TOLERANT_WHITELIST.some(path => url.includes(path));
}
/**
* 请求拦截器 - 自动添加 Authorization 头
*/
axiosInstance.interceptors.request.use(
(config) => {
// 检查是否在白名单中
if (isInAuthWhitelist(config.url)) {
console.log('🔓 [Request Interceptor] URL在白名单中,跳过Authorization:', config.url);
return config;
}
// 从 localStorage 获取 token (浏览器环境)
if (typeof window !== 'undefined') {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
console.log('🔑 [Request Interceptor] 添加Authorization头:', {
url: config.url,
method: config.method,
hasToken: !!token,
tokenPreview: token.substring(0, 20) + '...'
});
} else {
console.warn('⚠️ [Request Interceptor] 没有找到access_token:', {
url: config.url,
localStorage: Object.keys(localStorage)
});
}
}
return config;
},
(error) => {
console.error('❌ [Request Interceptor] 请求拦截器错误:', error);
return Promise.reject(error);
}
);
/**
* 自定义错误类:表示需要重新登录
*/
export class AuthenticationError extends Error {
constructor(message = 'Token 已过期或无效,请重新登录') {
super(message);
this.name = 'AuthenticationError';
}
}
/**
* 响应拦截器 - 处理 401 错误(token 过期)
*/
axiosInstance.interceptors.response.use(
(response) => {
console.log('✅ [Response Interceptor] 请求成功:', {
url: response.config.url,
status: response.status,
statusText: response.statusText
});
return response;
},
(error) => {
console.error('❌ [Response Interceptor] 请求失败:', {
url: error.config?.url,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data
});
if (isAxiosError(error) && error.response?.status === 401) {
// 检查是否在错误容忍白名单中
const requestUrl = error.config?.url;
if (isInErrorTolerantWhitelist(requestUrl)) {
console.warn('⚠️ [容错白名单] 接口返回 401,但不触发强制登出:', requestUrl);
// 直接返回错误,不触发登出
return Promise.reject(error);
}
// Token 过期或无效
console.warn('⚠️ Token 已过期或无效,请重新登录');
console.warn('⚠️ 401 错误详情:', {
url: requestUrl,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
headers: error.response?.headers
});
if (typeof window !== 'undefined') {
// 🌐 客户端环境:清除 localStorage 并跳转
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
window.location.href = '/login';
} else {
// 🖥️ 服务端环境:抛出特殊错误,由 loader/action 处理
console.warn('⚠️ [Server] 检测到 401 错误,抛出 AuthenticationError');
throw new AuthenticationError('Token 已过期或无效,请重新登录');
}
}
return Promise.reject(error);
}
);
// 最大重试次数
const MAX_RETRIES = 2;
/**
* 带重试功能的请求方法
* @param config Axios请求配置
* @param retries 当前重试次数
* @returns Axios响应
*/
async function axiosRetry(config: AxiosRequestConfig, retries = 0): Promise<AxiosResponse> {
try {
return await axiosInstance(config);
} catch (error) {
if (isAxiosError(error) && error.code === 'ECONNABORTED' && retries < MAX_RETRIES) {
console.log(`请求超时,第${retries + 1}次重试...`);
// 递增重试次数并重新发送请求
return axiosRetry(config, retries + 1);
}
throw error;
}
}
/**
* 将编码后的URL解码为可读格式
* 当前已注释掉日志,暂未使用此函数
* @param url 编码后的URL
* @returns 解码后的可读URL
* @unused 保留供将来使用
*/
// function decodeUrlForDisplay(url: string): string {
// try {
// // 首先解码整个URL
// const decodedUrl = decodeURIComponent(url);
//
// // 如果URL中包含@符号作为前缀,则移除它
// if (decodedUrl.startsWith('@')) {
// return decodedUrl.substring(1);
// }
//
// return decodedUrl;
// } catch (error) {
// // 如果解码失败,返回原始URL
// console.error('URL解码失败:', error);
// return url;
// }
// }
/**
* 构建完整的 API URL
*/
function buildUrl(endpoint: string, params?: QueryParams): string {
let fullUrl;
// 检查endpoint是否已经是完整URL
if (endpoint.startsWith('http')) {
fullUrl = endpoint;
} else {
// 处理相对路径的情况
if (API_BASE_URL === '/api') {
// 如果是相对路径,直接使用endpoint
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
fullUrl = path;
} else {
// 确保API_BASE_URL格式正确
const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
fullUrl = `${baseUrl}${path}`;
}
}
try {
// 创建URL对象
const url = new URL(fullUrl);
// 添加查询参数
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
// 处理数组参数:使用逗号分隔
if (Array.isArray(value)) {
url.searchParams.append(key, value.join(','));
} else {
url.searchParams.append(key, String(value));
}
}
});
}
return url.toString();
} catch (error) {
console.error('URL构建错误:', fullUrl, error);
throw new Error(`无法构建URL: ${fullUrl}, 错误: ${error}`);
}
}
/**
* 获取模拟响应数据
*/
function getMockResponse<T>(endpoint: string): ApiResponse<T> {
// console.log(`[开发模式] 使用模拟数据: ${endpoint}`);
// 移除开头的斜杠以便于匹配
const path = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
// 查找匹配的模拟数据
for (const mockPath in mockData) {
const normalizedMockPath = mockPath.startsWith('/') ? mockPath.substring(1) : mockPath;
if (path === normalizedMockPath || path.startsWith(normalizedMockPath + '/')) {
// 如果是详情查询 (如 /evaluation_points/1)
if (path.includes('/') && path !== normalizedMockPath) {
const id = parseInt(path.split('/')[1]);
const mockDataItem = mockData[mockPath as keyof typeof mockData] as MockApiResponse<unknown>;
if (Array.isArray(mockDataItem.data)) {
const item = mockDataItem.data.find((item: {id: number}) => item.id === id);
if (item) {
return {
data: {
code: 0,
msg: "成功",
data: item
} as unknown as T,
status: 200
};
}
}
return { error: '未找到数据', status: 404 };
}
// 返回列表数据
return {
data: mockData[mockPath as keyof typeof mockData] as unknown as T,
status: 200
};
}
}
return { error: '没有匹配的模拟数据', status: 404 };
}
/**
* 扩展AxiosRequestConfig类型,以支持body参数
*/
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
body?: string;
}
/**
* 通用 API 请求函数
*/
export async function apiRequest<T>(
endpoint: string,
options: ExtendedAxiosRequestConfig = {},
params?: QueryParams
): Promise<ApiResponse<T>> {
// 如果使用模拟数据,直接返回模拟响应
if (USE_MOCK_DATA) {
return getMockResponse<T>(endpoint);
}
// console.log('api-base-url-----------',API_BASE_URL)
try {
// 构建 URL
const url = buildUrl(endpoint, params);
// 只有在 options.headers 存在时才处理,否则让拦截器处理
let headers = options.headers;
if (headers) {
// 设置默认请求头(仅当 headers 已存在时)
if (!headers['Content-Type'] && options.method !== 'GET') {
headers['Content-Type'] = 'application/json';
}
if (!headers['Accept']) {
headers['Accept'] = 'application/json';
}
}
// 针对 PostgREST 的额外处理
if (endpoint.includes('evaluation_point_groups') && (options.method === 'POST' || options.method === 'PATCH')) {
// console.log('使用 PostgREST 特定配置处理请求');
// 确保请求体是有效的 JSON 对象
if (options.body && typeof options.body === 'string') {
try {
JSON.parse(options.body); // 验证 JSON 是否有效
} catch (e) {
console.error('请求体不是有效的 JSON:', options.body);
throw new Error('请求体必须是有效的 JSON');
}
}
}
// console.log(`📦 axios-client.ts->请求URL: ${url}`);
// console.log(`axios-client.ts->发送 ${options.method || 'GET'} 请求到: ${url}`);
// 处理body参数,转换为data
if (options.body) {
// console.log(`axios-client.ts->请求体: \n${options.body}`);
options.data = options.body;
} else if (options.data) {
// console.log(`axios-client.ts->请求体: \n${typeof options.data === 'string' ? options.data : JSON.stringify(options.data)}`);
}
// 构建请求配置
// 如果没有传入 headers,就不设置 headers,让拦截器自动添加
const config: AxiosRequestConfig = {
...options,
url,
// 确保使用默认超时时间
timeout: options.timeout || DEFAULT_TIMEOUT
};
// 只有在 headers 存在时才设置
if (headers) {
config.headers = headers;
// 🔍 调试:打印 Authorization 头
if (headers['Authorization']) {
// console.log('🔑 [apiRequest] 请求包含 Authorization 头:', headers['Authorization'].substring(0, 20) + '...');
} else {
console.warn('⚠️ [apiRequest] 请求缺少 Authorization 头!headers:', Object.keys(headers));
}
}
// console.log(`📦 axios-client.ts->请求配置: \n${JSON.stringify(config)}`);
// 删除body属性,避免axios警告
if ('body' in config) {
delete (config as ExtendedAxiosRequestConfig).body;
}
// 使用带重试功能的请求方法
const response: AxiosResponse = await axiosRetry(config);
// 收集响应头信息
const responseHeaders: Record<string, string> = {};
Object.entries(response.headers).forEach(([key, value]) => {
if (typeof value === 'string') {
responseHeaders[key] = value;
}
});
// 打印响应信息
// console.log(`响应状态: ${response.status}`);
// console.log(`响应头:`, responseHeaders);
// console.log(`响应体:`, response.data);
// 检查 PostgREST 特定错误
if (response.status >= 400) {
if (response.status === 400) {
console.error('PostgREST 错误 - 无效请求:', response.data);
return {
error: response.data?.message || response.data?.msg || '无效的请求格式,请检查数据格式是否正确',
status: response.status,
headers: responseHeaders
};
} else if (response.status === 401) {
console.error('PostgREST 错误 - 未授权:', response.data);
return {
error: response.data?.message || response.data?.msg || '未授权,请检查认证信息',
status: response.status,
headers: responseHeaders
};
} else if (response.status === 403) {
console.error('PostgREST 错误 - 禁止访问:', response.data);
return {
error: response.data?.message || response.data?.msg || '没有权限执行此操作',
status: response.status,
headers: responseHeaders
};
} else if (response.status === 404) {
console.error('PostgREST 错误 - 资源不存在:', response.data);
return {
error: response.data?.message || response.data?.msg || '请求的资源不存在',
status: response.status,
headers: responseHeaders
};
} else {
console.error(`HTTP请求失败: ${response.status} - ${url}`, response.data);
return {
error: response.data?.message || response.data?.msg || `请求失败: ${response.status}`,
status: response.status,
headers: responseHeaders
};
}
}
// 检查API返回的状态码
const data = response.data;
// 修复:支持code=0PostgREST)和code=200RBAC API)两种成功响应
if (data && typeof data === 'object' && 'code' in data && data.code !== 0 && data.code !== 200) {
const errorMessage = data.message || data.msg || '未知错误';
console.error(`API请求失败: ${errorMessage} - ${url}`);
// 🔑 检测令牌过期错误
const isTokenExpired = errorMessage.includes('令牌已过期') ||
errorMessage.includes('令牌') ||
errorMessage.includes('token') ||
errorMessage.includes('expired') ||
errorMessage.includes('认证') ||
errorMessage.includes('未授权');
if (isTokenExpired) {
console.error('🔑 [API Client] 检测到令牌过期,准备清除会话并重定向...');
// 只在客户端执行重定向
if (typeof window !== 'undefined') {
console.error('🔑 [API Client] 客户端环境,清除 localStorage 并重定向到登录页');
// 清除所有认证相关数据
localStorage.removeItem('access_token');
localStorage.removeItem('user_info');
sessionStorage.clear();
// 重定向到登录页
window.location.href = '/login?expired=true';
}
}
return {
error: errorMessage,
status: response.status,
headers: responseHeaders
};
}
return {
data,
status: response.status,
headers: responseHeaders
};
} catch (error: unknown) {
console.error('API请求失败:', error);
if (isAxiosError(error)) {
const status = error.response?.status || 500;
const errorData = error.response?.data;
// 如果超时或网络错误,使用模拟数据(仅开发环境)
if (error.code === 'ECONNABORTED' && process.env.NODE_ENV !== 'production') {
console.warn('自动使用模拟数据作为回退');
return getMockResponse<T>(endpoint);
}
return {
error: errorData?.message || errorData?.msg || error.message || '未知错误',
status
};
}
// 如果超时或网络错误,使用模拟数据(仅开发环境)
if (process.env.NODE_ENV !== 'production') {
console.warn('自动使用模拟数据作为回退');
return getMockResponse<T>(endpoint);
}
return {
error: error instanceof Error ? error.message : '未知错误',
status: 500
};
}
}
// GET请求简化方法
export async function get<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<T>> {
return apiRequest<T>(endpoint, { method: 'GET' }, params);
}
// POST请求简化方法
export async function post<T>(endpoint: string, data?: unknown, params?: QueryParams): Promise<ApiResponse<T>> {
return apiRequest<T>(endpoint, { method: 'POST', data }, params);
}
// PUT请求简化方法
export async function put<T>(endpoint: string, data?: unknown, params?: QueryParams): Promise<ApiResponse<T>> {
return apiRequest<T>(endpoint, { method: 'PUT', data }, params);
}
// PATCH请求简化方法
export async function patch<T>(endpoint: string, data?: unknown, params?: QueryParams): Promise<ApiResponse<T>> {
return apiRequest<T>(endpoint, { method: 'PATCH', data }, params);
}
// DELETE请求简化方法
export async function del<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<T>> {
return apiRequest<T>(endpoint, { method: 'DELETE' }, params);
}
// 下载文件的方法
export async function downloadFile(path: string): Promise<Blob> {
// 使用 PDF 代理路由获取文件,自动添加 JWT 认证
const downloadUrl = `/api/pdf-proxy?path=${encodeURIComponent(path)}`;
try {
// console.log(`📦 axios-client.ts->下载文件: ${downloadUrl}`);
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
}
return await response.blob();
} catch (error) {
console.error('下载文件失败:', error);
throw error;
}
}