Files
aiops/frontend/src/utils/request.ts
T
Logan.Li 1ac0a34717 feat(frontend): 401 redirect 回跳与启动期诊断输出
- 401 统一携带 redirect 跳登录,登录成功后回到原页面(防开放跳转校验)
- 并发 401 只清理会话并跳转一次
- 开发环境输出 [HTTP] 请求日志,新增 bootLog 启动横幅(API Base/Domain/Mode)
2026-09-14 15:27:54 +08:00

220 lines
7.0 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 { useUserStore } from '@/store';
import { getToken, clearToken } from './storage';
import i18n from '@/i18n';
const API_PREFIX = '/api/v1';
// 后端统一响应格式
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
}
// 列表响应格式
export interface ListResponse<T = unknown> {
list: T[];
total: number;
}
type Json = unknown;
// 并发 401 只清理会话并跳转一次,避免多个请求同时过期时重复登出/提示(借鉴 opsx-web-lite)。 // Logan.Li
let handling401 = false;
/** 401 统一处理:清理本地会话并携带 redirect 跳转登录页 // Logan.Li */
function handleUnauthorized(): void {
if (handling401) return;
handling401 = true;
clearToken();
useUserStore.getState().logout();
const current = window.location.pathname + window.location.search;
const target = current && current !== '/login'
? `/login?redirect=${encodeURIComponent(current)}`
: '/login';
window.location.href = target;
}
/** 统一 API 根路径,避免 get/post 与 download/upload 行为不一致 // Logan.Li */
export function getApiBase(): string {
const envBase = import.meta.env.VITE_API_BASE_URL;
if (envBase) {
const normalized = String(envBase).replace(/\/$/, '');
return normalized.endsWith(API_PREFIX) ? normalized : `${normalized}${API_PREFIX}`;
}
return API_PREFIX;
}
/** 对外展示的 API 根路径(curl/CI 文档),无环境变量时拼接当前浏览器域名 // Logan.Li */
export function getPublicApiBase(): string {
const base = getApiBase();
if (/^https?:\/\//i.test(base)) return base;
return `${window.location.origin}${base}`;
}
/** 拼接 REST API 完整路径(含 /api/v1 前缀) // Logan.Li */
export function buildApiUrl(path: string, params?: URLSearchParams): string {
const p = path.startsWith('/') ? path : `/${path}`;
let url = `${getApiBase()}${p}`;
const qs = params?.toString();
if (qs) url += `?${qs}`;
return url;
}
function resolveApiUrl(path: string): string {
return buildApiUrl(path);
}
/** 获取 WebSocket 根路径(不含 /api/v1) // Logan.Li */
export function getWsBase(): string {
const envBase = import.meta.env.VITE_API_BASE_URL;
if (envBase) {
const apiUrl = new URL(String(envBase), window.location.origin);
apiUrl.protocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:';
apiUrl.pathname = apiUrl.pathname.replace(/\/$/, '').replace(/\/api\/v1$/, '');
return apiUrl.toString().replace(/\/$/, '');
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}`;
}
export function getAuthToken(): string {
return getToken();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function request<T = any>(
method: string,
url: string,
body?: Json,
params?: Record<string, string | number | boolean | undefined>,
): Promise<T> {
let fullUrl = resolveApiUrl(url);
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
});
const qs = searchParams.toString();
if (qs) fullUrl += '?' + qs;
}
const token = getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = 'Bearer ' + token;
}
if (import.meta.env.DEV) {
// 开发环境输出请求日志,便于排查 404/代理类问题(借鉴 opsx-web-lite) // Logan.Li
console.log(`[HTTP] ${method} ${url}`);
}
const response = await fetch(fullUrl, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (import.meta.env.DEV) {
console.log(`[HTTP] ${method} ${url} → HTTP ${response.status}`);
}
if (response.status === 401) {
handleUnauthorized();
throw new Error(i18n.t('common.requestts.sd3ab821c'));
}
let json: ApiResponse<T>;
try {
json = await response.json();
} catch {
throw new Error(`HTTP ${response.status}: 响应解析失败`);
}
if (!response.ok || (json.code && json.code !== 200)) {
const msg = json.message || `HTTP ${response.status}`;
throw new Error(msg);
}
return (json.data !== undefined ? json.data : json) as T;
}
export const http = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: <T = any>(url: string, params?: Record<string, string | number | boolean | undefined>) =>
request<T>('GET', url, undefined, params),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
post: <T = any>(url: string, body?: Json) => request<T>('POST', url, body),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
put: <T = any>(url: string, body?: Json) => request<T>('PUT', url, body),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete: <T = any>(url: string, body?: Json) => request<T>('DELETE', url, body),
getRaw: async (url: string): Promise<string> => {
const resp = await fetch(resolveApiUrl(url), {
headers: { Authorization: 'Bearer ' + getAuthToken() },
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.text();
},
putRaw: async (url: string, body: string, contentType: string): Promise<unknown> => {
const resp = await fetch(resolveApiUrl(url), {
method: 'PUT',
headers: {
Authorization: 'Bearer ' + getAuthToken(),
'Content-Type': contentType,
},
body,
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
},
download: async (url: string, filename?: string): Promise<void> => {
const resp = await fetch(resolveApiUrl(url), {
headers: { Authorization: 'Bearer ' + getAuthToken() },
});
if (!resp.ok) {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('json')) {
try {
const json = await resp.json() as { message?: string; Message?: string };
throw new Error(json.message || json.Message || `HTTP ${resp.status}`);
} catch (e) {
if (e instanceof Error && !e.message.startsWith('HTTP ')) throw e;
}
}
throw new Error(`HTTP ${resp.status}`);
}
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const disp = resp.headers.get('Content-Disposition') || '';
const m = disp.match(/filename="?(.+?)"?$/);
a.download = filename || (m ? m[1] : 'download');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
upload: async <T = any>(url: string, formData: FormData): Promise<T> => {
const resp = await fetch(resolveApiUrl(url), {
method: 'POST',
headers: { Authorization: 'Bearer ' + getAuthToken() },
body: formData,
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
},
};
export default http;