Files
ftb-project-management/apps/web/lib/api.ts
d18bbfa14b merge: 集成V2.7 企业协作与管理治理
# Conflicts:
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	apps/web/lib/permissions.ts
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
2026-07-08 18:10:08 +08:00

156 lines
5.1 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.

type ApiEnv = {
NEXT_PUBLIC_API_URL?: string;
NODE_ENV?: string;
};
export function resolveApiBase(env: ApiEnv = process.env) {
const fallback = env.NODE_ENV === 'production' ? '/api/v1' : 'http://localhost:3001/api/v1';
const configured = (env.NEXT_PUBLIC_API_URL || fallback).replace(/\/$/, '');
if (env.NODE_ENV === 'production' && isLocalOnlyApiUrl(configured)) {
return '/api/v1';
}
return configured;
}
const API_BASE = resolveApiBase();
const AUTH_SESSION_KEY = 'ftb_auth_session';
const AUTH_PERSIST_KEY = 'ftb_auth_persist';
function isLocalOnlyApiUrl(value: string) {
try {
const url = new URL(value);
return ['localhost', '127.0.0.1', '0.0.0.0'].includes(url.hostname);
} catch {
return false;
}
}
let apiAvailable: boolean | null = null;
let probePromise: Promise<boolean> | null = null;
function getErrorMessage(body: unknown, status: number) {
if (typeof body === 'object' && body !== null && 'message' in body) {
const message = (body as { message?: unknown }).message;
if (typeof message === 'string') return message;
if (Array.isArray(message)) return message.join(', ');
}
return `Request failed: ${status}`;
}
export class ApiRequestError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown,
) {
super(getErrorMessage(body, status));
this.name = 'ApiRequestError';
}
}
async function checkApi(): Promise<boolean> {
if (apiAvailable === true) return true;
if (probePromise) return probePromise;
probePromise = (async () => {
try {
// 探测一个不依赖数据库的端点(/config/ai 总是返回 200只要 NestJS 起来了)
const res = await fetch(`${API_BASE}/config/ai`, { method: 'GET', signal: AbortSignal.timeout(1500) });
apiAvailable = res.ok ? true : null;
} catch {
apiAvailable = null;
} finally {
probePromise = null;
}
return apiAvailable === true;
})();
return probePromise;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const available = await checkApi();
if (!available) throw new Error('API 不可用');
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...resolveAuthHeaders(), ...options?.headers },
...options,
}).catch(() => null);
if (!res) {
apiAvailable = null;
throw new Error('API 不可用');
}
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new ApiRequestError(res.status, error);
}
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
patch: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
deleteWithBody: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'DELETE', body: JSON.stringify(data) }),
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...resolveAuthHeaders() },
body: JSON.stringify(data),
signal: controller.signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new ApiRequestError(res.status, err);
}
return res.json();
} finally {
clearTimeout(tid);
}
},
};
export function __resetApiAvailabilityForTests() {
apiAvailable = null;
probePromise = null;
}
function resolveAuthHeaders(): Record<string, string> {
const user = readStoredAuthUser();
if (!user?.id) return {};
return {
'x-ftb-user-id': user.id,
...(user.roleId ? { 'x-ftb-user-role-id': user.roleId } : {}),
...(user.name ? { 'x-ftb-user-name': encodeURIComponent(user.name) } : {}),
...(user.username ? { 'x-ftb-user-username': user.username } : {}),
...(user.email ? { 'x-ftb-user-email': user.email } : {}),
};
}
function readStoredAuthUser(): { id?: string; roleId?: string; name?: string; username?: string; email?: string } | null {
if (typeof sessionStorage === 'undefined') return null;
const raw = safeStorageGet(sessionStorage, AUTH_SESSION_KEY)
?? (typeof localStorage === 'undefined' ? null : safeStorageGet(localStorage, AUTH_PERSIST_KEY));
if (!raw) return null;
try {
const value = JSON.parse(raw);
return value && typeof value === 'object' ? value : null;
} catch {
return null;
}
}
function safeStorageGet(storage: Pick<Storage, 'getItem'>, key: string): string | null {
try {
return storage.getItem(key);
} catch {
return null;
}
}