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:
WeiCHEN
2026-09-22 21:26:44 +08:00
parent 364ed7f1a3
commit 681ca1b95e
24 changed files with 450 additions and 150 deletions
+39 -39
View File
@@ -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 {
// 网络失败仍清除本地态
}
+7 -3
View File
@@ -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 });
}
+3 -2
View File
@@ -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({
+2 -1
View File
@@ -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' }),
});
+3 -4
View File
@@ -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),
+11 -6
View File
@@ -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) {
+2 -1
View File
@@ -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}`);
+6 -5
View File
@@ -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) {
+111
View File
@@ -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;
}
});
+176
View File
@@ -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,
};
+2 -3
View File
@@ -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),
+2 -1
View File
@@ -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,
+9 -8
View File
@@ -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(),
});
+4 -3
View File
@@ -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',
+5 -4
View File
@@ -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) {
+4 -3
View File
@@ -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) {
+5 -4
View File
@@ -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',
+13 -14
View File
@@ -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',
+3 -4
View File
@@ -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 }),
+9 -10
View File
@@ -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),
+3 -3
View File
@@ -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
View File
@@ -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) {
+11 -6
View File
@@ -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) {
+6 -17
View File
@@ -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 ?? [];