536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
// ============================================================
|
||
// 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}`
|
||
}
|