forked from xiongyuxing/tiku-backend.net
- rowKey 使用 ?? 替代 ||,避免合法值 0 被当作 falsy - parseJson 增加非字符串类型防御检查 - toTree 增加递归深度限制防止循环引用栈溢出 - saveBatchNodes 增加空名称数组检查 - localStorage.setItem 捕获 QuotaExceededError - 路径参数缺失时显式抛出错误而非静默替换
110 lines
4.3 KiB
TypeScript
110 lines
4.3 KiB
TypeScript
import type { OperationInput, PlatformOperation } from './types';
|
||
import { tokenStore, type TokenPair } from './token-store';
|
||
|
||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
|
||
let refreshPromise: Promise<TokenPair | null> | null = null;
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
message: string,
|
||
readonly status: number,
|
||
readonly details?: unknown,
|
||
) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
async function parseResponse(response: Response): Promise<unknown> {
|
||
if (response.status === 204) return null;
|
||
const contentType = response.headers.get('content-type') || '';
|
||
if (contentType.includes('json')) return response.json();
|
||
const text = await response.text();
|
||
return text || null;
|
||
}
|
||
|
||
async function refreshTokens(): Promise<TokenPair | null> {
|
||
const tokens = tokenStore.get();
|
||
if (!tokens?.refreshToken) return null;
|
||
const response = await fetch(`${apiBaseUrl}/api/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refreshToken: tokens.refreshToken }),
|
||
});
|
||
if (!response.ok) {
|
||
tokenStore.set(null);
|
||
return null;
|
||
}
|
||
const next = (await response.json()) as TokenPair;
|
||
tokenStore.set(next);
|
||
return next;
|
||
}
|
||
|
||
async function authorizedFetch(url: string, init: RequestInit, retry = true): Promise<Response> {
|
||
const accessToken = tokenStore.get()?.accessToken;
|
||
const headers = new Headers(init.headers);
|
||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
|
||
const response = await fetch(`${apiBaseUrl}${url}`, { ...init, headers });
|
||
if (response.status !== 401 || !retry) return response;
|
||
refreshPromise ??= refreshTokens().finally(() => { refreshPromise = null; });
|
||
const refreshed = await refreshPromise;
|
||
return refreshed ? authorizedFetch(url, init, false) : response;
|
||
}
|
||
|
||
function appendQuery(url: URL, query: Record<string, unknown>) {
|
||
for (const [key, value] of Object.entries(query)) {
|
||
if (value === undefined || value === null || value === '') continue;
|
||
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, String(item)));
|
||
else url.searchParams.set(key, String(value));
|
||
}
|
||
}
|
||
|
||
export async function apiRequest<T = unknown>(
|
||
operation: PlatformOperation,
|
||
input: OperationInput = {},
|
||
signal?: AbortSignal,
|
||
): Promise<T> {
|
||
const pathValues = (input.path || {}) as Record<string, unknown>;
|
||
let route = operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
||
const value = pathValues[name];
|
||
if (value === undefined || value === null) throw new Error(`缺少必填路径参数:${name}`);
|
||
return encodeURIComponent(String(value));
|
||
});
|
||
const url = new URL(route, window.location.origin);
|
||
appendQuery(url, ((input.query || {}) as Record<string, unknown>));
|
||
route = `${url.pathname}${url.search}`;
|
||
const headers: Record<string, string> = {};
|
||
const init: RequestInit = { method: operation.method, headers, signal };
|
||
if (input.body !== undefined && operation.method !== 'GET') {
|
||
headers['Content-Type'] = 'application/json';
|
||
init.body = JSON.stringify(input.body);
|
||
}
|
||
const response = await authorizedFetch(route, init);
|
||
const payload = await parseResponse(response);
|
||
if (!response.ok) {
|
||
const problem = payload as { detail?: string; title?: string } | null;
|
||
throw new ApiError(problem?.detail || problem?.title || `请求失败(HTTP ${response.status})`, response.status, payload);
|
||
}
|
||
return payload as T;
|
||
}
|
||
|
||
export async function authRequest<T>(path: string, body?: unknown): Promise<T> {
|
||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
const payload = await parseResponse(response);
|
||
if (!response.ok) {
|
||
const problem = payload as { detail?: string; title?: string } | null;
|
||
throw new ApiError(problem?.detail || problem?.title || `请求失败(HTTP ${response.status})`, response.status, payload);
|
||
}
|
||
return payload as T;
|
||
}
|
||
|
||
export async function getCurrentUser<T>(): Promise<T> {
|
||
const response = await authorizedFetch('/api/me', { method: 'GET' });
|
||
const payload = await parseResponse(response);
|
||
if (!response.ok) throw new ApiError('登录会话已失效', response.status, payload);
|
||
return payload as T;
|
||
}
|