// ============================================================ // ZCLAW SaaS Admin — 类型化 HTTP 客户端 // ============================================================ import { getToken, login as saveToken, logout, getAccount } from './auth' import type { AccountPublic, AgentTemplate, ApiError, ConfigItem, CreateTokenRequest, DashboardStats, DailyUsageStat, LoginRequest, LoginResponse, Model, ModelUsageStat, OperationLog, PaginatedResponse, PromptTemplate, PromptVersion, Provider, ProviderKey, RelayTask, TokenInfo, UsageByModel, UsageRecord, } from './types' // ── 错误类 ──────────────────────────────────────────────── export class ApiRequestError extends Error { constructor( public status: number, public body: ApiError, ) { super(body.message || `Request failed with status ${status}`) this.name = 'ApiRequestError' } } // ── 基础请求 ────────────────────────────────────────────── const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || '/api/v1' const DEFAULT_TIMEOUT_MS = 10_000 const MAX_RETRIES = 2 function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } /** 判断是否为可重试的网络错误(不含 AbortError) */ function isRetryableNetworkError(err: unknown): boolean { // AbortError 不重试:可能是组件卸载或路由切换导致的外部取消 if (err instanceof DOMException && err.name === 'AbortError') return false if (err instanceof TypeError) { const msg = (err as TypeError).message return msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ECONNREFUSED') } return false } /** 尝试刷新 Token,成功返回新 token,失败返回 null */ async function tryRefreshToken(): Promise { try { const token = getToken() if (!token) return null const res = await fetch(`${BASE_URL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }) if (!res.ok) return null const data = await res.json() const newToken = data.token as string const account = getAccount() if (account && newToken) { saveToken(newToken, account) } return newToken } catch { return null } } async function request( method: string, path: string, body?: unknown, _isRetry = false, ): Promise { let lastError: unknown for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) try { const token = getToken() const headers: Record = { 'Content-Type': 'application/json', } if (token) { headers['Authorization'] = `Bearer ${token}` } const res = await fetch(`${BASE_URL}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }) clearTimeout(timeoutId) // 401: 尝试刷新 Token 后重试 if (res.status === 401 && !_isRetry) { const newToken = await tryRefreshToken() if (newToken) { return request(method, path, body, true) } logout() if (typeof window !== 'undefined') { window.location.href = '/login' } throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' }) } if (!res.ok) { let errorBody: ApiError try { errorBody = await res.json() } catch { errorBody = { error: 'unknown', message: `请求失败 (${res.status})` } } throw new ApiRequestError(res.status, errorBody) } // 204 No Content if (res.status === 204) { return undefined as T } return res.json() as Promise } catch (err) { clearTimeout(timeoutId) // API 错误和外部取消的 AbortError 直接抛出,不重试 if (err instanceof ApiRequestError) throw err if (err instanceof DOMException && err.name === 'AbortError') throw err lastError = err // 仅对可重试的网络错误重试 if (attempt < MAX_RETRIES && isRetryableNetworkError(err)) { await sleep(1000 * Math.pow(2, attempt)) continue } throw err } } throw lastError } // ── API 客户端 ──────────────────────────────────────────── export const api = { // ── 认证 ────────────────────────────────────────────── auth: { async login(data: LoginRequest): Promise { return request('POST', '/auth/login', data) }, async register(data: { username: string password: string email: string display_name?: string }): Promise { return request('POST', '/auth/register', data) }, async me(): Promise { return request('GET', '/auth/me') }, }, // ── 账号管理 ────────────────────────────────────────── accounts: { async list(params?: { page?: number page_size?: number search?: string role?: string status?: string }): Promise> { const qs = buildQueryString(params) return request>('GET', `/accounts${qs}`) }, async get(id: string): Promise { return request('GET', `/accounts/${id}`) }, async update( id: string, data: Partial>, ): Promise { return request('PATCH', `/accounts/${id}`, data) }, async updateStatus( id: string, data: { status: AccountPublic['status'] }, ): Promise { return request('PATCH', `/accounts/${id}/status`, data) }, }, // ── 服务商管理 ──────────────────────────────────────── providers: { async list(params?: { page?: number page_size?: number }): Promise> { const qs = buildQueryString(params) return request>('GET', `/providers${qs}`) }, async create(data: Partial>): Promise { return request('POST', '/providers', data) }, async update( id: string, data: Partial>, ): Promise { return request('PATCH', `/providers/${id}`, data) }, async delete(id: string): Promise { return request('DELETE', `/providers/${id}`) }, // Key Pool 管理 async listKeys(providerId: string): Promise { return request('GET', `/providers/${providerId}/keys`) }, async addKey(providerId: string, data: { key_label: string key_value: string priority?: number max_rpm?: number max_tpm?: number quota_reset_interval?: string }): Promise<{ ok: boolean; key_id: string }> { return request<{ ok: boolean; key_id: string }>('POST', `/providers/${providerId}/keys`, data) }, async toggleKey(providerId: string, keyId: string, active: boolean): Promise<{ ok: boolean }> { return request<{ ok: boolean }>('PUT', `/providers/${providerId}/keys/${keyId}/toggle`, { active }) }, async deleteKey(providerId: string, keyId: string): Promise<{ ok: boolean }> { return request<{ ok: boolean }>('DELETE', `/providers/${providerId}/keys/${keyId}`) }, }, // ── 模型管理 ────────────────────────────────────────── models: { async list(params?: { page?: number page_size?: number provider_id?: string }): Promise> { const qs = buildQueryString(params) return request>('GET', `/models${qs}`) }, async create(data: Partial>): Promise { return request('POST', '/models', data) }, async update(id: string, data: Partial>): Promise { return request('PATCH', `/models/${id}`, data) }, async delete(id: string): Promise { return request('DELETE', `/models/${id}`) }, }, // ── API 密钥 ────────────────────────────────────────── tokens: { async list(params?: { page?: number page_size?: number }): Promise> { const qs = buildQueryString(params) return request>('GET', `/keys${qs}`) }, async create(data: CreateTokenRequest): Promise { return request('POST', '/keys', data) }, async revoke(id: string): Promise { return request('DELETE', `/keys/${id}`) }, }, // ── 用量统计 ────────────────────────────────────────── usage: { async daily(params?: { days?: number }): Promise { const qs = buildQueryString({ ...params, group_by: 'day' }) const result = await request<{ by_day: UsageRecord[] }>('GET', `/usage${qs}`) return result.by_day || [] }, async byModel(params?: { days?: number }): Promise { const qs = buildQueryString({ ...params, group_by: 'model' }) const result = await request<{ by_model: UsageByModel[] }>('GET', `/usage${qs}`) return result.by_model || [] }, }, // ── 中转任务 ────────────────────────────────────────── relay: { async list(params?: { page?: number page_size?: number status?: string }): Promise> { const qs = buildQueryString(params) return request>('GET', `/relay/tasks${qs}`) }, async get(id: string): Promise { return request('GET', `/relay/tasks/${id}`) }, }, // ── 系统配置 ────────────────────────────────────────── config: { async list(params?: { category?: string page?: number page_size?: number }): Promise { const qs = buildQueryString(params) const result = await request>('GET', `/config/items${qs}`) return result.items }, async update(id: string, data: { value: string | number | boolean }): Promise { return request('PATCH', `/config/items/${id}`, data) }, }, // ── 操作日志 ────────────────────────────────────────── logs: { async list(params?: { page?: number page_size?: number action?: string }): Promise> { const qs = buildQueryString(params) return request>('GET', `/logs/operations${qs}`) }, }, // ── 仪表盘 ──────────────────────────────────────────── stats: { async dashboard(): Promise { return request('GET', '/stats/dashboard') }, }, // ── 提示词管理 ──────────────────────────────────────── prompts: { async list(params?: { category?: string source?: string status?: string page?: number page_size?: number }): Promise> { const qs = buildQueryString(params) return request>('GET', `/prompts${qs}`) }, async get(name: string): Promise { return request('GET', `/prompts/${encodeURIComponent(name)}`) }, async create(data: { name: string category: string description?: string source?: string system_prompt: string user_prompt_template?: string variables?: unknown[] min_app_version?: string }): Promise { return request('POST', '/prompts', data) }, async update(name: string, data: { description?: string status?: string }): Promise { return request('PUT', `/prompts/${encodeURIComponent(name)}`, data) }, async archive(name: string): Promise { return request('DELETE', `/prompts/${encodeURIComponent(name)}`) }, async listVersions(name: string): Promise { return request('GET', `/prompts/${encodeURIComponent(name)}/versions`) }, async createVersion(name: string, data: { system_prompt: string user_prompt_template?: string variables?: unknown[] changelog?: string min_app_version?: string }): Promise { return request('POST', `/prompts/${encodeURIComponent(name)}/versions`, data) }, async rollback(name: string, version: number): Promise { return request('POST', `/prompts/${encodeURIComponent(name)}/rollback/${version}`) }, }, // ── Agent 配置模板 ────────────────────────────────── agentTemplates: { async list(params?: { category?: string source?: string visibility?: string status?: string page?: number page_size?: number }): Promise> { const qs = buildQueryString(params) return request>('GET', `/agent-templates${qs}`) }, async get(id: string): Promise { return request('GET', `/agent-templates/${id}`) }, async create(data: { name: string description?: string category?: string source?: string model?: string system_prompt?: string tools?: string[] capabilities?: string[] temperature?: number max_tokens?: number visibility?: string }): Promise { return request('POST', '/agent-templates', data) }, async update(id: string, data: { description?: string model?: string system_prompt?: string tools?: string[] capabilities?: string[] temperature?: number max_tokens?: number visibility?: string status?: string }): Promise { return request('POST', `/agent-templates/${id}`, data) }, async archive(id: string): Promise { return request('DELETE', `/agent-templates/${id}`) }, }, // ── 遥测统计 ────────────────────────────────────────── telemetry: { /** 按模型聚合用量统计 */ async modelStats(params?: { from?: string to?: string model_id?: string connection_mode?: string }): Promise { const qs = buildQueryString(params) return request('GET', `/telemetry/stats${qs}`) }, /** 按天聚合用量统计 */ async dailyStats(params?: { days?: number }): Promise { const qs = buildQueryString(params) return request('GET', `/telemetry/daily${qs}`) }, }, } // ── 工具函数 ────────────────────────────────────────────── function buildQueryString(params?: Record): string { if (!params) return '' const entries = Object.entries(params).filter( ([, v]) => v !== undefined && v !== null && v !== '', ) if (entries.length === 0) return '' const qs = entries .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) .join('&') return `?${qs}` }