Files
leaudit-platform-frontend/app/api/axios-client.ts
T
LiangShiyong fb67f138dc fix: 1. 全局axios添加formData文件上传的检测,删除Content-Type让axios自动检测。
2. 完善入口模块管理的接口的对接。
3. 完善角色权限管理的接口对接和测试。
4. 完善主页的入口模块的图标的显示和图片的显示。
2025-11-29 19:37:29 +08:00

604 lines
20 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';
import { toastService } from '../components/ui/Toast';
/**
* 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) => {
// ⭐ 检测 FormData,删除默认的 Content-Type,让 axios 自动处理
if (config.data instanceof FormData) {
console.log('📦 [Request Interceptor] 检测到FormData,删除Content-Type让axios自动处理');
delete config.headers['Content-Type'];
}
// 检查是否在白名单中
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 已过期或无效,请重新登录');
}
}
// 🔒 403 无权限错误处理
if (isAxiosError(error) && error.response?.status === 403) {
console.warn('⚠️ [403 Forbidden] 无权限访问:', error.config?.url);
// 修改错误消息为友好提示,避免显示原始的 "Request failed with status code 403"
// 注意:不在这里显示 toast,由组件层统一处理,避免重复提示
error.message = '无权限';
}
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)两种成功响应
// 🔑 只有当 code 是数字类型时才认为是 API 状态码,字符串 code 是业务编码
if (data && typeof data === 'object' && 'code' in data &&
typeof data.code === 'number' && 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;
}
}