Files
zclaw_openfang/admin/src/lib/api-client.ts
iven 5fdf96c3f5 chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
2026-03-29 10:46:41 +08:00

536 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// 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<void> {
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<string | null> {
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<T>(
method: string,
path: string,
body?: unknown,
_isRetry = false,
): Promise<T> {
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<string, string> = {
'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<T>(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<T>
} 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<LoginResponse> {
return request<LoginResponse>('POST', '/auth/login', data)
},
async register(data: {
username: string
password: string
email: string
display_name?: string
}): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/auth/register', data)
},
async me(): Promise<AccountPublic> {
return request<AccountPublic>('GET', '/auth/me')
},
},
// ── 账号管理 ──────────────────────────────────────────
accounts: {
async list(params?: {
page?: number
page_size?: number
search?: string
role?: string
status?: string
}): Promise<PaginatedResponse<AccountPublic>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<AccountPublic>>('GET', `/accounts${qs}`)
},
async get(id: string): Promise<AccountPublic> {
return request<AccountPublic>('GET', `/accounts/${id}`)
},
async update(
id: string,
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
): Promise<AccountPublic> {
return request<AccountPublic>('PATCH', `/accounts/${id}`, data)
},
async updateStatus(
id: string,
data: { status: AccountPublic['status'] },
): Promise<void> {
return request<void>('PATCH', `/accounts/${id}/status`, data)
},
},
// ── 服务商管理 ────────────────────────────────────────
providers: {
async list(params?: {
page?: number
page_size?: number
}): Promise<PaginatedResponse<Provider>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<Provider>>('GET', `/providers${qs}`)
},
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
return request<Provider>('POST', '/providers', data)
},
async update(
id: string,
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
): Promise<Provider> {
return request<Provider>('PATCH', `/providers/${id}`, data)
},
async delete(id: string): Promise<void> {
return request<void>('DELETE', `/providers/${id}`)
},
// Key Pool 管理
async listKeys(providerId: string): Promise<ProviderKey[]> {
return request<ProviderKey[]>('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<PaginatedResponse<Model>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
},
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('POST', '/models', data)
},
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('PATCH', `/models/${id}`, data)
},
async delete(id: string): Promise<void> {
return request<void>('DELETE', `/models/${id}`)
},
},
// ── API 密钥 ──────────────────────────────────────────
tokens: {
async list(params?: {
page?: number
page_size?: number
}): Promise<PaginatedResponse<TokenInfo>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<TokenInfo>>('GET', `/keys${qs}`)
},
async create(data: CreateTokenRequest): Promise<TokenInfo> {
return request<TokenInfo>('POST', '/keys', data)
},
async revoke(id: string): Promise<void> {
return request<void>('DELETE', `/keys/${id}`)
},
},
// ── 用量统计 ──────────────────────────────────────────
usage: {
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
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<UsageByModel[]> {
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<PaginatedResponse<RelayTask>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
},
async get(id: string): Promise<RelayTask> {
return request<RelayTask>('GET', `/relay/tasks/${id}`)
},
},
// ── 系统配置 ──────────────────────────────────────────
config: {
async list(params?: {
category?: string
page?: number
page_size?: number
}): Promise<ConfigItem[]> {
const qs = buildQueryString(params)
const result = await request<PaginatedResponse<ConfigItem>>('GET', `/config/items${qs}`)
return result.items
},
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
return request<ConfigItem>('PATCH', `/config/items/${id}`, data)
},
},
// ── 操作日志 ──────────────────────────────────────────
logs: {
async list(params?: {
page?: number
page_size?: number
action?: string
}): Promise<PaginatedResponse<OperationLog>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<OperationLog>>('GET', `/logs/operations${qs}`)
},
},
// ── 仪表盘 ────────────────────────────────────────────
stats: {
async dashboard(): Promise<DashboardStats> {
return request<DashboardStats>('GET', '/stats/dashboard')
},
},
// ── 提示词管理 ────────────────────────────────────────
prompts: {
async list(params?: {
category?: string
source?: string
status?: string
page?: number
page_size?: number
}): Promise<PaginatedResponse<PromptTemplate>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<PromptTemplate>>('GET', `/prompts${qs}`)
},
async get(name: string): Promise<PromptTemplate> {
return request<PromptTemplate>('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<PromptTemplate> {
return request<PromptTemplate>('POST', '/prompts', data)
},
async update(name: string, data: {
description?: string
status?: string
}): Promise<PromptTemplate> {
return request<PromptTemplate>('PUT', `/prompts/${encodeURIComponent(name)}`, data)
},
async archive(name: string): Promise<PromptTemplate> {
return request<PromptTemplate>('DELETE', `/prompts/${encodeURIComponent(name)}`)
},
async listVersions(name: string): Promise<PromptVersion[]> {
return request<PromptVersion[]>('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<PromptVersion> {
return request<PromptVersion>('POST', `/prompts/${encodeURIComponent(name)}/versions`, data)
},
async rollback(name: string, version: number): Promise<PromptTemplate> {
return request<PromptTemplate>('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<PaginatedResponse<AgentTemplate>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<AgentTemplate>>('GET', `/agent-templates${qs}`)
},
async get(id: string): Promise<AgentTemplate> {
return request<AgentTemplate>('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<AgentTemplate> {
return request<AgentTemplate>('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<AgentTemplate> {
return request<AgentTemplate>('POST', `/agent-templates/${id}`, data)
},
async archive(id: string): Promise<AgentTemplate> {
return request<AgentTemplate>('DELETE', `/agent-templates/${id}`)
},
},
// ── 遥测统计 ──────────────────────────────────────────
telemetry: {
/** 按模型聚合用量统计 */
async modelStats(params?: {
from?: string
to?: string
model_id?: string
connection_mode?: string
}): Promise<ModelUsageStat[]> {
const qs = buildQueryString(params)
return request<ModelUsageStat[]>('GET', `/telemetry/stats${qs}`)
},
/** 按天聚合用量统计 */
async dailyStats(params?: {
days?: number
}): Promise<DailyUsageStat[]> {
const qs = buildQueryString(params)
return request<DailyUsageStat[]>('GET', `/telemetry/daily${qs}`)
},
},
}
// ── 工具函数 ──────────────────────────────────────────────
function buildQueryString(params?: Record<string, unknown>): 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}`
}