forked from xiongyuxing/tiku-backend.net
feat: add Vite configuration for Tiku.PlatformAdmin.Web and update documentation
- Introduced Vite configuration files (vite.config.js, vite.config.ts, vite.config.d.ts) for the React frontend. - Configured server proxy settings for API endpoints. - Added Vitest configuration files (vitest.config.js, vitest.config.ts, vitest.config.d.ts) for testing. - Updated architecture overview to reflect the separation of the platform admin frontend into its own React project. - Modified quickstart documentation to guide users on starting the platform admin frontend.
This commit is contained in:
105
Tiku.PlatformAdmin.Web/src/api/http.ts
Normal file
105
Tiku.PlatformAdmin.Web/src/api/http.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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) => encodeURIComponent(String(pathValues[name] ?? '')));
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user