- 401 统一携带 redirect 跳登录,登录成功后回到原页面(防开放跳转校验) - 并发 401 只清理会话并跳转一次 - 开发环境输出 [HTTP] 请求日志,新增 bootLog 启动横幅(API Base/Domain/Mode)
220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
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;
|