213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
import type { ApiPayload } from '@/types/api';
|
|
import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth';
|
|
import { isNativeAppRuntime } from './speech-capture';
|
|
|
|
const configuredApiBase = import.meta.env.VITE_API_BASE;
|
|
const currentApiBase = () => configuredApiBase
|
|
|| (isNativeAppRuntime() ? 'https://peilian.njzhmj.top/dev-api' : '/dev-api');
|
|
|
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
|
|
export interface RequestOptions {
|
|
url: string;
|
|
method?: HttpMethod;
|
|
data?: unknown;
|
|
header?: Record<string, string>;
|
|
timeout?: number;
|
|
auth?: boolean;
|
|
clearAuthOnUnauthorized?: boolean;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
readonly statusCode: number;
|
|
readonly bizCode?: number;
|
|
|
|
constructor(message: string, statusCode: number, bizCode?: number) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.statusCode = statusCode;
|
|
this.bizCode = bizCode;
|
|
}
|
|
}
|
|
|
|
export const isAuthApiError = (error: unknown): boolean =>
|
|
error instanceof ApiError
|
|
&& (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403);
|
|
|
|
const normalizeUrl = (url: string) => `${currentApiBase()}${url.startsWith('/') ? url : `/${url}`}`;
|
|
|
|
export const apiUrl = normalizeUrl;
|
|
|
|
export const apiMediaUrl = (url: string) => {
|
|
const normalized = normalizeUrl(url);
|
|
if (/^https?:\/\//i.test(normalized) || typeof window === 'undefined') return normalized;
|
|
const origin = window.location?.origin;
|
|
return /^https?:\/\//i.test(origin || '') ? new URL(normalized, `${origin}/`).toString() : normalized;
|
|
};
|
|
|
|
export const authHeaders = (json = true, includeAuth = true) => {
|
|
const auth = getAuth();
|
|
return {
|
|
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
...(includeAuth && auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
|
...(includeAuth && auth.clientId ? { clientid: auth.clientId } : {})
|
|
};
|
|
};
|
|
|
|
export const readPayload = <T>(statusCode: number, data: unknown, clearAuthOnUnauthorized = true): T => {
|
|
const payload = data as ApiPayload<T>;
|
|
const code = typeof payload?.code === 'number' ? payload.code : undefined;
|
|
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
|
|
// Only a real login failure (401) drops the local session; business 403
|
|
// (permission denied) must surface as a message, never a silent logout.
|
|
if (clearAuthOnUnauthorized && (statusCode === 401 || code === 401)) {
|
|
clearAuth();
|
|
redirectToLoginForAuthLoss();
|
|
}
|
|
throw new ApiError(payload?.msg || `HTTP ${statusCode}`, statusCode, code);
|
|
}
|
|
|
|
return payload?.data as T;
|
|
};
|
|
|
|
export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUnauthorized = true) => {
|
|
try {
|
|
return JSON.parse(text || '{}');
|
|
} catch (error) {
|
|
if (clearAuthOnUnauthorized && statusCode === 401) {
|
|
clearAuth();
|
|
redirectToLoginForAuthLoss();
|
|
}
|
|
throw error instanceof SyntaxError
|
|
? new ApiError(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败', statusCode)
|
|
: error;
|
|
}
|
|
};
|
|
|
|
export const readTextPayload = <T>(statusCode: number, text: string): T =>
|
|
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
|
|
|
|
const shouldUseXhr = () => !isNativeAppRuntime() && typeof XMLHttpRequest !== 'undefined';
|
|
|
|
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
|
const method = options.method || 'GET';
|
|
const hasBody = method !== 'GET' && options.data !== undefined;
|
|
const response = await new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open(method, normalizeUrl(options.url));
|
|
xhr.timeout = options.timeout || 30000;
|
|
Object.entries({
|
|
...authHeaders(hasBody, options.auth !== false),
|
|
...options.header
|
|
}).forEach(([key, value]) => xhr.setRequestHeader(key, value));
|
|
xhr.onload = () => {
|
|
try {
|
|
resolve({
|
|
status: xhr.status,
|
|
data: parsePayloadText(xhr.status, xhr.responseText || 'null', options.clearAuthOnUnauthorized !== false)
|
|
});
|
|
} catch (error) {
|
|
reject(error instanceof ApiError
|
|
? error
|
|
: new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败'));
|
|
}
|
|
};
|
|
xhr.onerror = () => reject(new Error('网络请求失败'));
|
|
xhr.ontimeout = () => reject(new Error('网络请求超时'));
|
|
xhr.send(hasBody ? JSON.stringify(options.data) : undefined);
|
|
});
|
|
|
|
return readPayload<T>(response.status, response.data, options.clearAuthOnUnauthorized !== false);
|
|
};
|
|
|
|
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
|
if (shouldUseXhr()) {
|
|
return requestWithXhr<T>(options);
|
|
}
|
|
|
|
const timeout = options.timeout || 30000;
|
|
const response = await new Promise<any>((resolve, reject) => {
|
|
let finished = false;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const finish = (callback: () => void) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
if (timer) clearTimeout(timer);
|
|
callback();
|
|
};
|
|
const task = uni.request({
|
|
url: normalizeUrl(options.url),
|
|
method: options.method || 'GET',
|
|
data: options.data as string | ArrayBuffer | Record<string, unknown> | undefined,
|
|
header: {
|
|
...authHeaders(options.method !== 'GET', options.auth !== false),
|
|
...options.header
|
|
},
|
|
timeout,
|
|
success: (result) => finish(() => resolve(result)),
|
|
fail: (error) => finish(() => reject(error))
|
|
});
|
|
if (!finished) {
|
|
timer = setTimeout(() => finish(() => {
|
|
try {
|
|
task.abort();
|
|
} finally {
|
|
reject(new Error('网络请求超时'));
|
|
}
|
|
}), timeout);
|
|
}
|
|
});
|
|
|
|
return readPayload<T>(response.statusCode, response.data, options.clearAuthOnUnauthorized !== false);
|
|
};
|
|
|
|
export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
|
const fullUrl = normalizeUrl(url);
|
|
if (shouldUseXhr() && typeof URL !== 'undefined') {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('GET', fullUrl);
|
|
xhr.responseType = 'blob';
|
|
Object.entries(authHeaders(false)).forEach(([key, value]) => xhr.setRequestHeader(key, value));
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve(URL.createObjectURL(xhr.response));
|
|
return;
|
|
}
|
|
if (xhr.status === 401) {
|
|
clearAuth();
|
|
redirectToLoginForAuthLoss();
|
|
}
|
|
reject(new Error(`HTTP ${xhr.status}`));
|
|
};
|
|
xhr.onerror = () => reject(new Error('网络请求失败'));
|
|
xhr.send();
|
|
});
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
uni.downloadFile({
|
|
url: fullUrl,
|
|
header: authHeaders(false),
|
|
success: (response) => {
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
resolve(response.tempFilePath);
|
|
return;
|
|
}
|
|
if (response.statusCode === 401) {
|
|
clearAuth();
|
|
redirectToLoginForAuthLoss();
|
|
}
|
|
reject(new Error(`HTTP ${response.statusCode}`));
|
|
},
|
|
fail: () => reject(new Error('网络请求失败'))
|
|
});
|
|
});
|
|
};
|
|
|
|
export const releaseAuthenticatedFileUrl = (url: string) => {
|
|
if (url.startsWith('blob:') && typeof URL !== 'undefined') {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
};
|