feat(web): unify API client with auth, errors, and limited retry
Route web/src/api modules through shared apiFetch so Bearer headers, typed error envelopes, and GET/HEAD transient retries live in one place without swallowing domain failures used by existing error cards.
This commit is contained in:
+39
-39
@@ -11,6 +11,7 @@ import {
|
||||
} from '../types/auth';
|
||||
import { RoleDefinition } from '../types/rbac';
|
||||
import { mapLoginResult, mapMeResult, mapUserAccount, mapUserAccountList } from './authUserMap';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session';
|
||||
|
||||
@@ -24,12 +25,11 @@ export interface AuthClientOptions {
|
||||
localFallback?: boolean;
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const win = (globalThis as { window?: Window }).window;
|
||||
if (!win || typeof win.fetch !== 'function') {
|
||||
async function authRequest(path: string, init?: RequestInit, withAuth = false): Promise<Response> {
|
||||
if (typeof fetch !== 'function') {
|
||||
throw new Error('后端服务不可用,请检查网络连接');
|
||||
}
|
||||
return win.fetch(path, init);
|
||||
return apiFetch(path, init, { auth: withAuth, retry: false });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,9 +40,9 @@ export class AuthClient {
|
||||
|
||||
async fetchProfile(): Promise<Record<string, unknown>> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/me/profile', {
|
||||
const resp = await authRequest('/api/v1/me/profile', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('无法加载个人资料');
|
||||
}
|
||||
@@ -51,14 +51,14 @@ export class AuthClient {
|
||||
|
||||
async savePreferences(prefs: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/me/preferences', {
|
||||
const resp = await authRequest('/api/v1/me/preferences', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(prefs),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('保存偏好失败');
|
||||
}
|
||||
@@ -67,9 +67,9 @@ export class AuthClient {
|
||||
|
||||
async fetchMe(): Promise<MeResult> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/auth/me', {
|
||||
const resp = await authRequest('/api/v1/auth/me', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('会话已过期或未登录');
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export class AuthClient {
|
||||
if (!trimmedUser || !payload.password) {
|
||||
throw new Error('用户名和密码不能为空');
|
||||
}
|
||||
const resp = await apiFetch('/api/v1/auth/login', {
|
||||
const resp = await authRequest('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -120,7 +120,7 @@ export class AuthClient {
|
||||
}
|
||||
|
||||
async register(payload: RegisterPayload): Promise<RegisterResult> {
|
||||
const resp = await apiFetch('/api/v1/auth/register', {
|
||||
const resp = await authRequest('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -134,14 +134,14 @@ export class AuthClient {
|
||||
|
||||
async changePassword(_userId: number, oldPass: string, newPass: string): Promise<boolean> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/auth/change-password', {
|
||||
const resp = await authRequest('/api/v1/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ old_password: oldPass, new_password: newPass }),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text();
|
||||
throw new Error(txt.trim() || '修改密码失败');
|
||||
@@ -151,9 +151,9 @@ export class AuthClient {
|
||||
|
||||
async getAdminUsers(): Promise<UserAccount[]> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/admin/users', {
|
||||
const resp = await authRequest('/api/v1/admin/users', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('获取用户列表失败');
|
||||
}
|
||||
@@ -171,7 +171,7 @@ export class AuthClient {
|
||||
|
||||
async createAdminUser(payload: CreateAdminUserPayload): Promise<UserAccount> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/admin/users', {
|
||||
const resp = await authRequest('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -186,7 +186,7 @@ export class AuthClient {
|
||||
department: payload.department,
|
||||
employee_id: payload.employeeId,
|
||||
}),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('创建用户失败');
|
||||
}
|
||||
@@ -195,34 +195,34 @@ export class AuthClient {
|
||||
|
||||
async updateUserRole(userId: number, roleId: string, position: string): Promise<UserAccount> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch(`/api/v1/admin/users/${userId}/role`, {
|
||||
const resp = await authRequest(`/api/v1/admin/users/${userId}/role`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ role_id: roleId, position }),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) throw new Error('更新角色失败');
|
||||
return mapUserAccount(await resp.json());
|
||||
}
|
||||
|
||||
async updateUserStatus(userId: number, status: AccountStatus, roleId?: string, position?: string): Promise<UserAccount> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch(`/api/v1/admin/users/${userId}/status`, {
|
||||
const resp = await authRequest(`/api/v1/admin/users/${userId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ status, role_id: roleId, position }),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) throw new Error('更新状态失败');
|
||||
return mapUserAccount(await resp.json());
|
||||
}
|
||||
|
||||
async getSystemAuthConfig(): Promise<SystemAuthConfig> {
|
||||
const resp = await apiFetch('/api/v1/auth/public-config');
|
||||
const resp = await authRequest('/api/v1/auth/public-config');
|
||||
if (!resp.ok) {
|
||||
return { allowRegistration: false, requireApproval: true };
|
||||
}
|
||||
@@ -238,9 +238,9 @@ export class AuthClient {
|
||||
|
||||
async listRoles(): Promise<RoleDefinition[]> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/admin/roles', {
|
||||
const resp = await authRequest('/api/v1/admin/roles', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('获取角色列表失败');
|
||||
}
|
||||
@@ -255,14 +255,14 @@ export class AuthClient {
|
||||
permissions: string[];
|
||||
}): Promise<RoleDefinition> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/admin/roles', {
|
||||
const resp = await authRequest('/api/v1/admin/roles', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('创建角色失败');
|
||||
}
|
||||
@@ -274,14 +274,14 @@ export class AuthClient {
|
||||
payload: { name: string; description?: string; permissions: string[] }
|
||||
): Promise<RoleDefinition> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, {
|
||||
const resp = await authRequest(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('更新角色失败');
|
||||
}
|
||||
@@ -290,10 +290,10 @@ export class AuthClient {
|
||||
|
||||
async deleteRole(roleId: string): Promise<void> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, {
|
||||
const resp = await authRequest(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('删除角色失败');
|
||||
}
|
||||
@@ -303,9 +303,9 @@ export class AuthClient {
|
||||
Array<{ id: string; created_at: string; last_seen_at: string; user_agent: string; is_current: boolean }>
|
||||
> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/auth/sessions', {
|
||||
const resp = await authRequest('/api/v1/auth/sessions', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('获取会话列表失败');
|
||||
}
|
||||
@@ -315,10 +315,10 @@ export class AuthClient {
|
||||
|
||||
async revokeSession(sessionId: string): Promise<void> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch(`/api/v1/auth/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
const resp = await authRequest(`/api/v1/auth/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
throw new Error('撤销会话失败');
|
||||
}
|
||||
@@ -326,14 +326,14 @@ export class AuthClient {
|
||||
|
||||
async updateSystemAuthConfig(newConfig: SystemAuthConfig): Promise<SystemAuthConfig> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/admin/config', {
|
||||
const resp = await authRequest('/api/v1/admin/config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(newConfig),
|
||||
});
|
||||
}, true);
|
||||
if (!resp.ok) throw new Error('更新系统配置失败');
|
||||
return resp.json();
|
||||
}
|
||||
@@ -341,10 +341,10 @@ export class AuthClient {
|
||||
async logout(): Promise<void> {
|
||||
const token = this.getSessionToken();
|
||||
try {
|
||||
await apiFetch('/api/v1/auth/logout', {
|
||||
await authRequest('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}, true);
|
||||
} catch {
|
||||
// 网络失败仍清除本地态
|
||||
}
|
||||
|
||||
@@ -32,8 +32,12 @@ export function mergeAuthHeaders(
|
||||
return { ...getBearerAuthHeaders(contentType), ...extra };
|
||||
}
|
||||
|
||||
/** Authenticated fetch wrapper; merges Bearer token into every request. */
|
||||
export async function authorizedFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
/**
|
||||
* Authenticated fetch without retry. Prefer `apiFetch` from `./client` for new code
|
||||
* (base URL + typed errors + limited retry). Kept for callers that only need headers.
|
||||
*/
|
||||
export async function authorizedFetch(input: RequestInfo | string, init?: RequestInit): Promise<Response> {
|
||||
const path = typeof input === 'string' ? input : String(input);
|
||||
const extra =
|
||||
init?.headers instanceof Headers
|
||||
? Object.fromEntries(init.headers.entries())
|
||||
@@ -41,5 +45,5 @@ export async function authorizedFetch(input: RequestInfo, init?: RequestInit): P
|
||||
const contentType =
|
||||
typeof extra['Content-Type'] === 'string' ? extra['Content-Type'] : undefined;
|
||||
const headers = mergeAuthHeaders(extra, contentType);
|
||||
return fetch(input, { ...init, headers });
|
||||
return fetch(path, { ...init, headers });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DailyDigest } from '../types/workbench';
|
||||
import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export async function fetchDailyDigests(
|
||||
sessionId: string,
|
||||
@@ -11,7 +12,7 @@ export async function fetchDailyDigests(
|
||||
if (from) url.searchParams.set('from', from);
|
||||
if (to) url.searchParams.set('to', to);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
const res = await apiFetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: mergeAuthHeaders({ Accept: 'application/json' }),
|
||||
});
|
||||
@@ -28,7 +29,7 @@ export async function generateDailyDigest(
|
||||
sessionId: string,
|
||||
dayKey: string
|
||||
): Promise<{ queued: boolean; digest: DailyDigest }> {
|
||||
const res = await fetch('/api/v1/chat/digests/generate', {
|
||||
const res = await apiFetch('/api/v1/chat/digests/generate', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChatSearchHit, ChatSearchMode } from '../types/workbench';
|
||||
import { mergeAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export interface SearchChatMessagesParams {
|
||||
sessionId: string;
|
||||
@@ -32,7 +33,7 @@ export async function searchChatMessages(params: SearchChatMessagesParams): Prom
|
||||
url.searchParams.set('limit', String(params.limit));
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
const res = await apiFetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: mergeAuthHeaders({ Accept: 'application/json' }),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { projectSettingsErrorFromResponse } from './projectSettingsError';
|
||||
import { apiFetch, API_BASE } from './client';
|
||||
|
||||
export interface ChatSessionSummary {
|
||||
session_id: string;
|
||||
@@ -19,8 +20,6 @@ export interface UpsertChatSessionInput {
|
||||
project_id: number;
|
||||
}
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export async function fetchChatSessions(
|
||||
userId: number,
|
||||
projectId?: number,
|
||||
@@ -34,7 +33,7 @@ export async function fetchChatSessions(
|
||||
if (projectId != null && projectId > 0) {
|
||||
params.set('project_id', String(projectId));
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/sessions?${params.toString()}`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/chat/sessions?${params.toString()}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
signal,
|
||||
});
|
||||
@@ -46,7 +45,7 @@ export async function fetchChatSessions(
|
||||
}
|
||||
|
||||
export async function upsertChatSession(input: UpsertChatSessionInput): Promise<ChatSessionSummary> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/sessions`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/chat/sessions`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(input),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChatErrorInfo } from '../types/workbench';
|
||||
import type { MessageExportCapability } from '../types/replyExport';
|
||||
import { fallbackErrorClass } from './chatRetry';
|
||||
import { mergeAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type StreamEvent = {
|
||||
id?: string;
|
||||
@@ -169,12 +170,16 @@ export async function streamChat(
|
||||
},
|
||||
'application/json',
|
||||
);
|
||||
const resp = await fetch('/api/v1/chat/stream', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const resp = await apiFetch(
|
||||
'/api/v1/chat/stream',
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
},
|
||||
{ retry: false },
|
||||
);
|
||||
const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id');
|
||||
if (rid) handlers.onRequestId?.(rid);
|
||||
if (!resp.ok || !resp.body) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type ChatTurnStatus = {
|
||||
sessionId: string;
|
||||
@@ -15,7 +16,7 @@ export async function fetchChatTurnStatus(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChatTurnStatus | null> {
|
||||
const url = `/api/v1/chat/turns/${encodeURIComponent(clientTurnId)}?session_id=${encodeURIComponent(sessionId)}`;
|
||||
const res = await fetch(url, { signal, headers: getBearerAuthHeaders() });
|
||||
const res = await apiFetch(url, { signal, headers: getBearerAuthHeaders() });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetchChatTurnStatus failed: HTTP ${res.status}`);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { MessageContentKind, MessageExportCapability } from '../types/replyExport';
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { mapErrorInfo } from './chatStream';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
/** 从 metadata.task_phase_snapshot 重建前端快照(兼容 JSON 对象)。 */
|
||||
export function parseTaskPhaseSnapshot(raw: unknown): TaskPhaseSnapshot | undefined {
|
||||
@@ -288,7 +289,7 @@ export async function fetchChatWindow(
|
||||
url += `&before_day=${encodeURIComponent(beforeDay)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { signal, headers: getBearerAuthHeaders() });
|
||||
const res = await apiFetch(url, { signal, headers: getBearerAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
throw Object.assign(new Error(`fetchChatWindow failed: HTTP ${res.status}`), {
|
||||
httpStatus: res.status,
|
||||
@@ -323,7 +324,7 @@ export async function fetchDayMessages(
|
||||
if (options?.afterId) params.set('after_id', String(options.afterId));
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
|
||||
const res = await fetch(`/api/v1/chat/messages/day?${params.toString()}`, {
|
||||
const res = await apiFetch(`/api/v1/chat/messages/day?${params.toString()}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -343,7 +344,7 @@ export async function fetchDayMessages(
|
||||
}
|
||||
|
||||
export async function fetchChatDates(sessionId: string): Promise<ChatDateStat[]> {
|
||||
const res = await fetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
const res = await apiFetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -362,7 +363,7 @@ export async function fetchChatDates(sessionId: string): Promise<ChatDateStat[]>
|
||||
export async function importChatMessages(
|
||||
messages: Array<Record<string, any>>
|
||||
): Promise<{ imported_count: number; total: number }> {
|
||||
const res = await fetch('/api/v1/chat/messages/import', {
|
||||
const res = await apiFetch('/api/v1/chat/messages/import', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(messages),
|
||||
@@ -374,7 +375,7 @@ export async function importChatMessages(
|
||||
}
|
||||
|
||||
export async function fetchChatCategories(sessionId: string): Promise<ChatCategoryStat[]> {
|
||||
const res = await fetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
const res = await apiFetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { apiFetch, readApiError, ApiError, API_BASE } from './client';
|
||||
|
||||
test('API_BASE is empty relative base', () => {
|
||||
assert.equal(API_BASE, '');
|
||||
});
|
||||
|
||||
test('apiFetch attaches Bearer from session storage', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
const store: Record<string, string> = {
|
||||
smdf_auth_session: JSON.stringify({ token: 'tok-abc' }),
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
localStorage: {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store[k] = v;
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete store[k];
|
||||
},
|
||||
},
|
||||
};
|
||||
let seenAuth = '';
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
seenAuth = headers?.Authorization ?? '';
|
||||
return new Response('{}', { status: 200 });
|
||||
};
|
||||
try {
|
||||
await apiFetch('/api/v1/chat/sessions');
|
||||
assert.equal(seenAuth, 'Bearer tok-abc');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test('apiFetch retries GET on 503 then succeeds', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new Response('busy', { status: 503 });
|
||||
}
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
};
|
||||
try {
|
||||
const resp = await apiFetch('/api/v1/ping');
|
||||
assert.equal(resp.status, 200);
|
||||
assert.equal(calls, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('apiFetch does not retry POST by default', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response('busy', { status: 503 });
|
||||
};
|
||||
try {
|
||||
const resp = await apiFetch('/api/v1/ping', { method: 'POST' });
|
||||
assert.equal(resp.status, 503);
|
||||
assert.equal(calls, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('readApiError preserves typed envelope for error cards', async () => {
|
||||
const resp = new Response(
|
||||
JSON.stringify({ code: 'AUTHZ', message: '无权访问', error: 'ERR_FORBIDDEN' }),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
const err = await readApiError(resp, 'fallback');
|
||||
assert.ok(err instanceof ApiError);
|
||||
assert.equal(err.status, 403);
|
||||
assert.equal(err.code, 'AUTHZ');
|
||||
assert.equal(err.message, '无权访问');
|
||||
});
|
||||
|
||||
test('apiFetch auth:false skips Authorization', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
localStorage: {
|
||||
getItem: () => JSON.stringify({ token: 'secret' }),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
},
|
||||
};
|
||||
let seenAuth: string | undefined;
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
seenAuth = headers?.Authorization;
|
||||
return new Response('{}', { status: 200 });
|
||||
};
|
||||
try {
|
||||
await apiFetch('/api/v1/auth/login', { method: 'POST' }, { auth: false });
|
||||
assert.equal(seenAuth, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Shared frontend API client: base URL, Bearer auth, typed error envelope, limited retry.
|
||||
* Domain modules should call apiFetch / readApiError instead of bare fetch.
|
||||
*/
|
||||
|
||||
import { mergeAuthHeaders } from './authHeaders';
|
||||
import {
|
||||
ProjectSettingsApiError,
|
||||
parseProjectSettingsErrorBody,
|
||||
projectSettingsErrorFromResponse,
|
||||
} from './projectSettingsError';
|
||||
|
||||
/** Relative base; Vite proxy / same-origin deploy leave this empty. */
|
||||
export const API_BASE = '';
|
||||
|
||||
/** Typed API failure; same shape as ProjectSettingsApiError for existing error cards. */
|
||||
export class ApiError extends ProjectSettingsApiError {
|
||||
constructor(input: {
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
issues?: { code?: string; field?: string; message?: string }[];
|
||||
}) {
|
||||
super(input);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiFetchOptions = {
|
||||
/** Attach Bearer from session (default true). Login/register set false. */
|
||||
auth?: boolean;
|
||||
/**
|
||||
* Limited retry for network errors and 502/503/504.
|
||||
* Default: 2 for GET/HEAD, 0 otherwise. Pass false to disable (SSE / non-idempotent).
|
||||
*/
|
||||
retry?: boolean | number;
|
||||
};
|
||||
|
||||
function resolveUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (!API_BASE) return path;
|
||||
if (path.startsWith('/')) return `${API_BASE}${path}`;
|
||||
return `${API_BASE}/${path}`;
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
return resolveUrl(path);
|
||||
}
|
||||
|
||||
function normalizeHeaders(
|
||||
init?: RequestInit,
|
||||
auth = true,
|
||||
): Record<string, string> {
|
||||
const extra =
|
||||
init?.headers instanceof Headers
|
||||
? Object.fromEntries(init.headers.entries())
|
||||
: ((init?.headers as Record<string, string> | undefined) ?? {});
|
||||
if (!auth) {
|
||||
return { ...extra };
|
||||
}
|
||||
const contentType =
|
||||
typeof extra['Content-Type'] === 'string'
|
||||
? extra['Content-Type']
|
||||
: typeof extra['content-type'] === 'string'
|
||||
? extra['content-type']
|
||||
: undefined;
|
||||
return mergeAuthHeaders(extra, contentType);
|
||||
}
|
||||
|
||||
function methodOf(init?: RequestInit): string {
|
||||
return (init?.method ?? 'GET').toUpperCase();
|
||||
}
|
||||
|
||||
function maxAttempts(method: string, retry: ApiFetchOptions['retry']): number {
|
||||
if (retry === false) return 1;
|
||||
if (typeof retry === 'number') return Math.max(1, retry + 1);
|
||||
if (retry === true) return 3;
|
||||
// Default: only safe methods get limited retries (2 retries → 3 attempts).
|
||||
if (method === 'GET' || method === 'HEAD') return 3;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
function retryDelayMs(attemptIndex: number): number {
|
||||
return attemptIndex === 0 ? 200 : 500;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch with optional limited retry. Returns the raw Response;
|
||||
* callers still inspect status and throw domain errors (do not swallow).
|
||||
*/
|
||||
export async function apiFetch(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<Response> {
|
||||
const url = resolveUrl(path);
|
||||
const auth = options?.auth !== false;
|
||||
const method = methodOf(init);
|
||||
const attempts = maxAttempts(method, options?.retry);
|
||||
const headers = normalizeHeaders(init, auth);
|
||||
const signal = init?.signal;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(url, { ...init, headers });
|
||||
if (
|
||||
attempt < attempts - 1 &&
|
||||
isRetryableStatus(resp.status) &&
|
||||
!signal?.aborted
|
||||
) {
|
||||
await sleep(retryDelayMs(attempt));
|
||||
continue;
|
||||
}
|
||||
return resp;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (signal?.aborted) throw err;
|
||||
if (attempt < attempts - 1) {
|
||||
await sleep(retryDelayMs(attempt));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('请求失败');
|
||||
}
|
||||
|
||||
/** Parse typed error envelope from a failed Response (body consumed). */
|
||||
export async function readApiError(
|
||||
resp: Response,
|
||||
fallback: string,
|
||||
): Promise<ApiError> {
|
||||
const text = await resp.text();
|
||||
const base = projectSettingsErrorFromResponse(resp.status, text, fallback);
|
||||
return new ApiError({
|
||||
status: base.status,
|
||||
code: base.code,
|
||||
message: base.message,
|
||||
issues: base.issues,
|
||||
});
|
||||
}
|
||||
|
||||
/** Build ApiError from already-read response text. */
|
||||
export function apiErrorFromBody(
|
||||
status: number,
|
||||
text: string,
|
||||
fallback: string,
|
||||
): ApiError {
|
||||
const base = projectSettingsErrorFromResponse(status, text, fallback);
|
||||
return new ApiError({
|
||||
status: base.status,
|
||||
code: base.code,
|
||||
message: base.message,
|
||||
issues: base.issues,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ProjectSettingsApiError,
|
||||
parseProjectSettingsErrorBody,
|
||||
projectSettingsErrorFromResponse,
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
/** 受控文档正文保存、编辑租约与生命周期流转 API */
|
||||
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
|
||||
const API_BASE = '';
|
||||
import { apiFetch, API_BASE } from './client';
|
||||
|
||||
export interface SaveDocumentContentPayload {
|
||||
doc_id: string;
|
||||
@@ -78,7 +77,7 @@ export interface ApproverDecisionPayload {
|
||||
}
|
||||
|
||||
async function postDocGov<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
const resp = await apiFetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
RevisionState,
|
||||
} from '../features/documentRevision/types';
|
||||
import { mergeAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const extra =
|
||||
@@ -15,7 +16,7 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const contentType =
|
||||
typeof extra['Content-Type'] === 'string' ? extra['Content-Type'] : 'application/json';
|
||||
const headers = mergeAuthHeaders(extra, contentType);
|
||||
const res = await fetch(path, {
|
||||
const res = await apiFetch(path, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { projectSettingsErrorFromResponse } from './projectSettingsError';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export interface FreeformDocSummary {
|
||||
doc_id: string;
|
||||
@@ -54,7 +55,7 @@ export async function createFreeformDoc(payload: {
|
||||
space?: 'my_private' | 'project_shared';
|
||||
clientKey?: string;
|
||||
}): Promise<FreeformDocDetail> {
|
||||
const res = await fetch('/api/v1/freeform-documents', {
|
||||
const res = await apiFetch('/api/v1/freeform-documents', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
@@ -83,7 +84,7 @@ export async function saveMessageToPrivate(
|
||||
title?: string,
|
||||
expectedSha256?: string,
|
||||
): Promise<CreateFromMessageResponse> {
|
||||
const res = await fetch('/api/v1/freeform-documents/from-message', {
|
||||
const res = await apiFetch('/api/v1/freeform-documents/from-message', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
@@ -114,7 +115,7 @@ export async function listFreeformDocs(
|
||||
url += `&project_id=${projectId}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { headers: getBearerAuthHeaders() });
|
||||
const res = await apiFetch(url, { headers: getBearerAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`获取列表失败 (${res.status})`);
|
||||
}
|
||||
@@ -126,7 +127,7 @@ export async function listFreeformDocs(
|
||||
* 获取自由文档详情与 Markdown 正文
|
||||
*/
|
||||
export async function fetchFreeformDoc(docId: string): Promise<FreeformDocDetail> {
|
||||
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
||||
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -162,7 +163,7 @@ export async function updateFreeformDoc(
|
||||
headers['If-Match'] = `"${payload.expectedSha256}"`;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
||||
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
@@ -186,7 +187,7 @@ export async function updateFreeformDoc(
|
||||
* 一键共享至项目参考库
|
||||
*/
|
||||
export async function shareFreeformDoc(docId: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/share`, {
|
||||
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/share`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
@@ -200,7 +201,7 @@ export async function shareFreeformDoc(docId: string): Promise<void> {
|
||||
* 撤回共享,恢复为个人私密
|
||||
*/
|
||||
export async function unshareFreeformDoc(docId: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/unshare`, {
|
||||
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/unshare`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
@@ -214,7 +215,7 @@ export async function unshareFreeformDoc(docId: string): Promise<void> {
|
||||
* 软删除文档移至回收站
|
||||
*/
|
||||
export async function deleteFreeformDoc(docId: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
||||
const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type IntentPlanStatus =
|
||||
| 'proposed'
|
||||
@@ -44,7 +45,7 @@ export async function fetchActiveIntentPlan(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IntentPlanView | null> {
|
||||
const res = await fetch(`/api/v1/intent-plans/active?${scopeQuery(projectId, sessionId)}`, {
|
||||
const res = await apiFetch(`/api/v1/intent-plans/active?${scopeQuery(projectId, sessionId)}`, {
|
||||
signal,
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
@@ -62,7 +63,7 @@ export async function fetchIntentPlan(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IntentPlanView | null> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`/api/v1/intent-plans/${encodeURIComponent(planId)}?${scopeQuery(projectId, sessionId)}`,
|
||||
{ signal, headers: getBearerAuthHeaders() },
|
||||
);
|
||||
@@ -90,7 +91,7 @@ export async function submitIntentPlanDecision(
|
||||
input: IntentPlanDecisionInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IntentPlanEnvelope & { httpStatus: number }> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`/api/v1/intent-plans/${encodeURIComponent(input.planId)}/decisions?${scopeQuery(input.projectId, input.sessionId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type ProjectAsset = {
|
||||
asset_id: number;
|
||||
@@ -22,7 +23,7 @@ export async function uploadProjectAsset(
|
||||
): Promise<ProjectAsset> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, filename);
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/assets`, {
|
||||
const res = await apiFetch(`/api/v1/projects/${projectId}/assets`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders(),
|
||||
body: form,
|
||||
@@ -34,7 +35,7 @@ export async function uploadProjectAsset(
|
||||
}
|
||||
|
||||
export async function listProjectAssets(projectId: number): Promise<ProjectAsset[]> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/assets`, {
|
||||
const res = await apiFetch(`/api/v1/projects/${projectId}/assets`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -48,7 +49,7 @@ export async function reparseProjectAsset(
|
||||
projectId: number,
|
||||
assetId: number,
|
||||
): Promise<{ asset_id: number; parse_state: string }> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/assets/${assetId}/reparse`, {
|
||||
const res = await apiFetch(`/api/v1/projects/${projectId}/assets/${assetId}/reparse`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
@@ -66,7 +67,7 @@ export function projectAssetRawURL(projectId: number, assetId: number): string {
|
||||
|
||||
/** 拉取资产二进制为 Blob(带 Bearer)。 */
|
||||
export async function fetchProjectAssetBlob(projectId: number, assetId: number): Promise<Blob> {
|
||||
const res = await fetch(projectAssetRawURL(projectId, assetId), {
|
||||
const res = await apiFetch(projectAssetRawURL(projectId, assetId), {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** 项目工作流受控文档 API */
|
||||
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type WorkflowDocumentSummary = {
|
||||
doc_id: string;
|
||||
@@ -54,7 +55,7 @@ export type ControlledDocumentContent = {
|
||||
export async function listProjectWorkflowDocuments(
|
||||
projectId: number,
|
||||
): Promise<WorkflowDocumentSummary[]> {
|
||||
const resp = await fetch(
|
||||
const resp = await apiFetch(
|
||||
`/api/v1/projects/${projectId}/documents?category=workflow`,
|
||||
{ headers: getBearerAuthHeaders() },
|
||||
);
|
||||
@@ -71,7 +72,7 @@ export async function listProjectWorkflowDocuments(
|
||||
export async function listProjectWorkflowDocumentsDetailed(
|
||||
projectId: number,
|
||||
): Promise<ProjectWorkflowDocumentsResponse> {
|
||||
const resp = await fetch(
|
||||
const resp = await apiFetch(
|
||||
`/api/v1/projects/${projectId}/documents?category=workflow`,
|
||||
{ headers: getBearerAuthHeaders() },
|
||||
);
|
||||
@@ -106,7 +107,7 @@ export class ControlledContentRequestError extends Error {
|
||||
export async function fetchControlledDocumentContent(
|
||||
docId: string,
|
||||
): Promise<ControlledDocumentContent> {
|
||||
const resp = await fetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`, {
|
||||
const resp = await apiFetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { projectSettingsErrorFromResponse } from './projectSettingsError';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type ApiProjectMemberRole = 'owner' | 'member' | 'viewer';
|
||||
export type ApiProjectMemberStatus = 'active' | 'exited';
|
||||
@@ -34,7 +35,7 @@ async function memberApiError(res: Response, fallback: string) {
|
||||
}
|
||||
|
||||
export async function listProjectMembers(projectId: number): Promise<ApiProjectMemberRecord[]> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/members`, {
|
||||
const res = await apiFetch(`/api/v1/projects/${projectId}/members`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -48,7 +49,7 @@ export async function addProjectMember(
|
||||
projectId: number,
|
||||
payload: UpsertProjectMemberPayload
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/members`, {
|
||||
const res = await apiFetch(`/api/v1/projects/${projectId}/members`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(payload),
|
||||
@@ -62,7 +63,7 @@ export async function updateProjectMember(
|
||||
projectId: number,
|
||||
payload: UpsertProjectMemberPayload
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/members`, {
|
||||
const res = await apiFetch(`/api/v1/projects/${projectId}/members`, {
|
||||
method: 'PUT',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(payload),
|
||||
@@ -73,7 +74,7 @@ export async function updateProjectMember(
|
||||
}
|
||||
|
||||
export async function removeProjectMember(projectId: number, userId: number): Promise<void> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`/api/v1/projects/${projectId}/members?user_id=${encodeURIComponent(String(userId))}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
} from '../types/projectSettings';
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
import { projectSettingsErrorFromResponse } from './projectSettingsError';
|
||||
|
||||
const API_BASE = '';
|
||||
import { apiFetch, API_BASE } from './client';
|
||||
|
||||
export async function fetchDefaultWorkflowPlan(
|
||||
projectId: number,
|
||||
@@ -21,7 +20,7 @@ export async function fetchDefaultWorkflowPlan(
|
||||
): Promise<WorkflowPlan> {
|
||||
const params = new URLSearchParams({ workflow_key: workflowKey });
|
||||
if (version) params.set('version', String(version));
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`${API_BASE}/api/v1/projects/${projectId}/workflow-plan/default?${params}`,
|
||||
{ headers: getBearerAuthHeaders() }
|
||||
);
|
||||
@@ -33,7 +32,7 @@ export async function fetchDefaultWorkflowPlan(
|
||||
}
|
||||
|
||||
export async function fetchProjectSettings(projectId: number): Promise<ProjectSettings> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/settings`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/settings`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -50,7 +49,7 @@ export async function saveWorkflowPlan(
|
||||
expectedSelectionVersion: number,
|
||||
liveEdit = false
|
||||
): Promise<ProjectSettings> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, {
|
||||
method: 'PUT',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
@@ -77,7 +76,7 @@ export async function reassignDocTaskDuties(
|
||||
duty_policy?: string;
|
||||
}
|
||||
): Promise<ProjectSettings> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`${API_BASE}/api/v1/projects/${projectId}/doc-tasks/${encodeURIComponent(taskKey)}/duties`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
@@ -100,7 +99,7 @@ export async function reassignDocTaskDuties(
|
||||
}
|
||||
|
||||
export async function fetchActivationPreview(projectId: number): Promise<ActivationPreview> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/activation-preview`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/activation-preview`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: '{}',
|
||||
@@ -120,7 +119,7 @@ export async function activateWorkflowInstance(
|
||||
expected_plan_revision: number;
|
||||
}
|
||||
): Promise<ActivateResult> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-instances/activate`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-instances/activate`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(input),
|
||||
@@ -136,7 +135,7 @@ export async function fetchStageReadiness(
|
||||
instanceId: number,
|
||||
stageKey: string
|
||||
): Promise<StageReadiness> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`${API_BASE}/api/v1/workflow-instances/${instanceId}/stages/${encodeURIComponent(stageKey)}/readiness`,
|
||||
{ headers: getBearerAuthHeaders() }
|
||||
);
|
||||
@@ -154,7 +153,7 @@ export async function fetchProjectNotifications(
|
||||
const params = new URLSearchParams();
|
||||
if (sinceId && sinceId > 0) params.set('since_id', String(sinceId));
|
||||
const qs = params.toString();
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`${API_BASE}/api/v1/projects/${projectId}/notifications${qs ? `?${qs}` : ''}`,
|
||||
{ headers: getBearerAuthHeaders() }
|
||||
);
|
||||
@@ -168,7 +167,7 @@ export async function fetchProjectNotifications(
|
||||
}
|
||||
|
||||
export async function fetchWorkflowCatalog(): Promise<WorkflowCatalogItem[]> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/workflows/catalog`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/workflows/catalog`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -179,7 +178,7 @@ export async function fetchWorkflowCatalog(): Promise<WorkflowCatalogItem[]> {
|
||||
}
|
||||
|
||||
export async function fetchTerminationPreview(instanceId: number): Promise<TerminationPreview> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/termination-preview`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/termination-preview`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -193,7 +192,7 @@ export async function terminateWorkflowInstance(
|
||||
instanceId: number,
|
||||
input: { reason: string; confirm: boolean; expected_config_revision: number }
|
||||
): Promise<{ instance: { id: number; status: string }; reason: string }> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/terminate`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/terminate`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(input),
|
||||
@@ -211,7 +210,7 @@ export async function advanceStage(
|
||||
expectedConfigRevision: number,
|
||||
acknowledgeRecheck = false
|
||||
): Promise<AdvanceStageResult> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`${API_BASE}/api/v1/workflow-instances/${instanceId}/stages/${encodeURIComponent(stageKey)}/advance`,
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
|
||||
const API_BASE = '';
|
||||
import { apiFetch, API_BASE } from './client';
|
||||
|
||||
export interface TraceItem {
|
||||
id: number;
|
||||
@@ -34,7 +33,7 @@ export interface ProjectTraceView {
|
||||
}
|
||||
|
||||
export async function fetchProjectTrace(projectId: number): Promise<ProjectTraceView> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/trace`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -55,7 +54,7 @@ export async function clearRecheckMark(
|
||||
reason: 'no_change' | 'revision_started',
|
||||
messageId?: number,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({ reason, message_id: messageId || 0 }),
|
||||
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
CreateProjectInput,
|
||||
} from '../types/projectWorkflow';
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
|
||||
const API_BASE = '';
|
||||
import { apiFetch, API_BASE } from './client';
|
||||
|
||||
export async function fetchProjects(): Promise<Project[]> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -23,7 +22,7 @@ export async function fetchProjects(): Promise<Project[]> {
|
||||
export async function fetchProjectTypesCatalog(): Promise<
|
||||
Array<{ key: string; name: string; description: string; default_workflow_key: string }>
|
||||
> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-types/catalog`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/project-types/catalog`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -34,7 +33,7 @@ export async function fetchProjectTypesCatalog(): Promise<
|
||||
}
|
||||
|
||||
export async function createProject(data: CreateProjectInput): Promise<Project> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(data),
|
||||
@@ -47,7 +46,7 @@ export async function createProject(data: CreateProjectInput): Promise<Project>
|
||||
}
|
||||
|
||||
export async function getProject(id: number): Promise<Project> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${id}`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${id}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -57,7 +56,7 @@ export async function getProject(id: number): Promise<Project> {
|
||||
}
|
||||
|
||||
export async function fetchProjectWorkflows(projectId: number): Promise<ProjectWorkflow[]> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflows`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -67,7 +66,7 @@ export async function fetchProjectWorkflows(projectId: number): Promise<ProjectW
|
||||
}
|
||||
|
||||
export async function fetchWorkflowCatalog(): Promise<WorkflowDefinition[]> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/workflows`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/workflows`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -83,7 +82,7 @@ export async function fetchProjectLineage(
|
||||
const url = docId
|
||||
? `${API_BASE}/api/v1/projects/${projectId}/lineage?doc_id=${encodeURIComponent(docId)}`
|
||||
: `${API_BASE}/api/v1/projects/${projectId}/lineage`;
|
||||
const res = await fetch(url, {
|
||||
const res = await apiFetch(url, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -97,7 +96,7 @@ export async function runProjectWorkflow(
|
||||
workflowKey: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows/${workflowKey}/run`, {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflows/${workflowKey}/run`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(payload),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authorizedFetch } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
import type {
|
||||
SystemConfigValue,
|
||||
SystemFeature,
|
||||
@@ -22,7 +22,7 @@ async function readError(resp: Response, fallback: string): Promise<Error> {
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, fallback: string): Promise<T> {
|
||||
const resp = await authorizedFetch(path);
|
||||
const resp = await apiFetch(path);
|
||||
if (!resp.ok) throw await readError(resp, fallback);
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export async function updateSystemSetting(
|
||||
if (expectedRevision !== undefined) {
|
||||
body.expected_revision = expectedRevision;
|
||||
}
|
||||
const resp = await authorizedFetch(`${BASE}/settings/${encodeURIComponent(key)}`, {
|
||||
const resp = await apiFetch(`${BASE}/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
|
||||
+14
-9
@@ -1,6 +1,7 @@
|
||||
import { parseSSEChunk, type StreamEvent } from './chatStream';
|
||||
import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders';
|
||||
import { projectSettingsErrorFromResponse } from './projectSettingsError';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type CreateTaskRunBody = Record<string, unknown> & {
|
||||
session_id: string;
|
||||
@@ -17,7 +18,7 @@ export type CreateTaskRunResponse = {
|
||||
};
|
||||
|
||||
export async function createTaskRun(body: CreateTaskRunBody): Promise<CreateTaskRunResponse> {
|
||||
const res = await fetch('/api/v1/tasks/runs', {
|
||||
const res = await apiFetch('/api/v1/tasks/runs', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(body),
|
||||
@@ -60,11 +61,15 @@ export async function readTaskRunSSEOnce(
|
||||
): Promise<{ terminal: boolean; lastSeq: number }> {
|
||||
const replay = opts?.replayOnly ? '&replay_only=1' : '';
|
||||
const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${afterSeq}${replay}`;
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: mergeAuthHeaders({ Accept: 'text/event-stream' }),
|
||||
signal,
|
||||
});
|
||||
const resp = await apiFetch(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: mergeAuthHeaders({ Accept: 'text/event-stream' }),
|
||||
signal,
|
||||
},
|
||||
{ retry: false },
|
||||
);
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new TaskRunHttpError(resp.status);
|
||||
}
|
||||
@@ -141,7 +146,7 @@ export async function subscribeTaskRunEvents(
|
||||
}
|
||||
|
||||
export async function resumeTaskRun(runId: string): Promise<CreateTaskRunResponse> {
|
||||
const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/resume`, {
|
||||
const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/resume`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
@@ -154,7 +159,7 @@ export async function resumeTaskRun(runId: string): Promise<CreateTaskRunRespons
|
||||
}
|
||||
|
||||
export async function cancelTaskRun(runId: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, {
|
||||
const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
@@ -164,7 +169,7 @@ export async function cancelTaskRun(runId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getTaskRun(runId: string) {
|
||||
const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, {
|
||||
const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { parseSSEChunk, type StreamEvent } from './chatStream';
|
||||
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
|
||||
import type { TaskRunContext } from '../types/workbench';
|
||||
import { mergeAuthHeaders } from './authHeaders';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export type { TaskRunContext };
|
||||
|
||||
@@ -17,12 +18,16 @@ export async function streamTask(
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const resp = await fetch('/api/v1/tasks/run/stream', {
|
||||
method: 'POST',
|
||||
headers: mergeAuthHeaders({ Accept: 'text/event-stream' }, 'application/json'),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const resp = await apiFetch(
|
||||
'/api/v1/tasks/run/stream',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: mergeAuthHeaders({ Accept: 'text/event-stream' }, 'application/json'),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
},
|
||||
{ retry: false },
|
||||
);
|
||||
const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id');
|
||||
if (rid) handlers.onRequestId?.(rid);
|
||||
if (!resp.ok || !resp.body) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authClient } from './auth';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export interface WorkflowDefinitionSummary {
|
||||
id: number;
|
||||
@@ -24,19 +24,8 @@ export interface ValidationIssue {
|
||||
field?: string;
|
||||
}
|
||||
|
||||
async function adminFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const token = authClient.getSessionToken();
|
||||
return fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminWorkflows(): Promise<WorkflowDefinitionSummary[]> {
|
||||
const resp = await adminFetch('/api/v1/admin/workflows');
|
||||
const resp = await apiFetch('/api/v1/admin/workflows');
|
||||
if (!resp.ok) throw new Error('获取工作流列表失败');
|
||||
const data = await resp.json();
|
||||
return data.workflows ?? [];
|
||||
@@ -46,7 +35,7 @@ export async function getAdminWorkflow(id: number): Promise<{
|
||||
definition: WorkflowDefinitionSummary;
|
||||
versions: WorkflowVersionSummary[];
|
||||
}> {
|
||||
const resp = await adminFetch(`/api/v1/admin/workflows/${id}`);
|
||||
const resp = await apiFetch(`/api/v1/admin/workflows/${id}`);
|
||||
if (!resp.ok) throw new Error('获取工作流详情失败');
|
||||
return resp.json();
|
||||
}
|
||||
@@ -55,7 +44,7 @@ export async function validateWorkflowVersion(workflowId: number, version: numbe
|
||||
valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
}> {
|
||||
const resp = await adminFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/validate`, {
|
||||
const resp = await apiFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/validate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) throw new Error('校验失败');
|
||||
@@ -63,7 +52,7 @@ export async function validateWorkflowVersion(workflowId: number, version: numbe
|
||||
}
|
||||
|
||||
export async function publishWorkflowVersion(workflowId: number, version: number): Promise<void> {
|
||||
const resp = await adminFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/publish`, {
|
||||
const resp = await apiFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/publish`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
@@ -75,7 +64,7 @@ export async function publishWorkflowVersion(workflowId: number, version: number
|
||||
export async function listAdminTemplates(): Promise<
|
||||
Array<{ id: number; asset_key: string; name: string; status: string }>
|
||||
> {
|
||||
const resp = await adminFetch('/api/v1/admin/templates');
|
||||
const resp = await apiFetch('/api/v1/admin/templates');
|
||||
if (!resp.ok) throw new Error('获取模板列表失败');
|
||||
const data = await resp.json();
|
||||
return data.templates ?? [];
|
||||
|
||||
Reference in New Issue
Block a user