import { ApiResponse, AudioTestResponse, TestResponse, AnalyticsOverviewData, AnalyticsTrendData, AnalyticsBreakdownData, AnalyticsEventsData, AnalyticsBreakdownDimension, AnalyticsRange, BillingConfig, SystemConfig, AdminLoginResponse, UsageLogFilters, UsageLogSearchData, } from '@/types' import { clearAdminCredentials, getStoredAdminCredential } from '@/lib/admin-auth' const BASE_URL = import.meta.env.VITE_API_BASE_URL || '' // 错误类型 export class ApiError extends Error { status: number constructor(message: string, status: number) { super(message) this.name = 'ApiError' this.status = status } } // 全局错误处理回调 let onUnauthorized: (() => void) | null = null let onError: ((error: ApiError) => void) | null = null type RequestBehavior = { skipUnauthorizedHandler?: boolean } export function setErrorHandlers(handlers: { onUnauthorized?: () => void onError?: (error: ApiError) => void }) { onUnauthorized = handlers.onUnauthorized || null onError = handlers.onError || null } // 请求拦截器 async function requestInterceptor(config: RequestInit): Promise { const headers = new Headers(config.headers) // 设置默认 Content-Type if (!headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } // 添加 admin token const credential = getStoredAdminCredential() if (credential) { headers.set(credential.headerName, credential.token) } return { ...config, headers } } // 响应拦截器 async function responseInterceptor(response: Response, behavior: RequestBehavior = {}): Promise { if (!response.ok) { let errorMessage: string // 先读取为文本,避免 body stream already read 错误 const responseText = await response.text() try { // 尝试解析为 JSON const errorData = JSON.parse(responseText) if (typeof errorData?.message === 'string') { errorMessage = errorData.message } else if (typeof errorData?.error === 'string') { errorMessage = errorData.error } else { errorMessage = JSON.stringify(errorData) } } catch { // 不是 JSON,直接使用文本 errorMessage = responseText || `请求失败: ${response.status}` } const error = new ApiError(errorMessage, response.status) // 处理特定状态码 switch (response.status) { case 401: if (!behavior.skipUnauthorizedHandler) { clearAdminCredentials() onUnauthorized?.() error.message = '认证已过期,请重新登录' } break case 403: error.message = '没有权限执行此操作' break case 404: error.message = '请求的资源不存在' break case 500: error.message = '服务器内部错误' break case 502: case 503: case 504: error.message = '服务暂时不可用,请稍后重试' break } // 触发全局错误回调 onError?.(error) throw error } const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { return response.json() } if (contentType && contentType.startsWith('audio/')) { const blob = await response.blob() return { object: 'audio', contentType, size: blob.size, } } return response.text() } function buildAudioFilename(endpoint: string, contentType: string): string { const extension = contentType.split('/')[1]?.split(';')[0] || 'bin' const suffix = endpoint.split('/').filter(Boolean).join('-') || 'response' return `${suffix}.${extension}` } function buildQueryString(params: Record): string { const searchParams = new URLSearchParams() Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== '') { searchParams.set(key, String(value)) } }) const query = searchParams.toString() return query ? `?${query}` : '' } // 统一请求方法 async function request( endpoint: string, options: RequestInit = {}, behavior: RequestBehavior = {}, ): Promise { const url = `${BASE_URL}${endpoint}` try { const config = await requestInterceptor(options) const response = await fetch(url, config) return responseInterceptor(response, behavior) } catch (error) { // 处理网络错误 if (error instanceof TypeError && error.message.includes('fetch')) { const networkError = new ApiError('网络连接失败,请检查网络', 0) onError?.(networkError) throw networkError } throw error } } // API 方法 export const apiClient = { // 通用方法 get: (endpoint: string) => request(endpoint, { method: 'GET' }), post: (endpoint: string, data?: unknown) => request(endpoint, { method: 'POST', body: data ? JSON.stringify(data) : undefined, }), delete: (endpoint: string) => request(endpoint, { method: 'DELETE' }), // Channel APIs getChannels: () => request('/api/admin/channel', { method: 'GET' }), saveChannel: (key: string, config: unknown) => request(`/api/admin/channel/${encodeURIComponent(key)}`, { method: 'POST', body: JSON.stringify(config), }), fetchChannelModels: (config: unknown) => request('/api/admin/channel/models/fetch', { method: 'POST', body: JSON.stringify(config), }), deleteChannel: (key: string) => request(`/api/admin/channel/${encodeURIComponent(key)}`, { method: 'DELETE' }), // Token APIs getTokens: () => request('/api/admin/token', { method: 'GET' }), saveToken: (key: string, config: unknown) => request(`/api/admin/token/${encodeURIComponent(key)}`, { method: 'POST', body: JSON.stringify(config), }), deleteToken: (key: string) => request(`/api/admin/token/${encodeURIComponent(key)}`, { method: 'DELETE' }), resetTokenUsage: (key: string) => request(`/api/admin/token/${encodeURIComponent(key)}/reset`, { method: 'POST' }), // Pricing APIs getPricing: () => request('/api/admin/pricing', { method: 'GET' }), savePricing: (config: unknown) => request('/api/admin/pricing', { method: 'POST', body: JSON.stringify(config), }), getBillingConfig: () => request>('/api/admin/billing/config', { method: 'GET' }), saveBillingConfig: (config: BillingConfig) => request>('/api/admin/billing/config', { method: 'POST', body: JSON.stringify(config), }), getSystemConfig: () => request>('/api/admin/system/config', { method: 'GET' }), saveSystemConfig: (config: SystemConfig) => request>('/api/admin/system/config', { method: 'POST', body: JSON.stringify(config), }), sendTelegramTestMessage: (payload: Pick) => request>('/api/admin/system/telegram/test', { method: 'POST', body: JSON.stringify(payload), }), // Analytics APIs getAnalyticsOverview: (range: AnalyticsRange) => request>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }), getAnalyticsTrend: (range: AnalyticsRange) => request>(`/api/admin/analytics/trend?range=${encodeURIComponent(range)}`, { method: 'GET' }), getAnalyticsBreakdown: (range: AnalyticsRange, dimension: AnalyticsBreakdownDimension) => request>( `/api/admin/analytics/breakdown?range=${encodeURIComponent(range)}&dimension=${encodeURIComponent(dimension)}`, { method: 'GET' } ), getAnalyticsEvents: (range: AnalyticsRange, limit = 40) => request>( `/api/admin/analytics/events?range=${encodeURIComponent(range)}&limit=${encodeURIComponent(String(limit))}`, { method: 'GET' } ), getUsageLogs: (filters: UsageLogFilters) => request>( `/api/admin/usage-logs${buildQueryString({ start: filters.start, end: filters.end, dimension: filters.dimension, keyword: filters.keyword, result: filters.result, page: filters.page, })}`, { method: 'GET' } ), // API Test - 使用自定义 token,不走通用拦截器 testApi: async (endpoint: string, token: string, body: unknown): Promise => { const url = `${BASE_URL}${endpoint}` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify(body), }) if (response.ok) { const contentType = response.headers.get('content-type') || '' if (contentType.startsWith('audio/')) { const blob = await response.blob() const audioResponse: AudioTestResponse = { object: 'audio', contentType, size: blob.size, url: URL.createObjectURL(blob), filename: buildAudioFilename(endpoint, contentType), } return audioResponse } } return responseInterceptor(response) }, // Auth check checkAuth: () => request('/api/admin/channel', { method: 'GET' }), startAdminLogin: (token: string) => request>('/api/admin/auth/login', { method: 'POST', body: JSON.stringify({ token }), }, { skipUnauthorizedHandler: true, }), verifyAdminLogin: (challengeId: string, code: string) => request>('/api/admin/auth/verify', { method: 'POST', body: JSON.stringify({ challengeId, code }), }, { skipUnauthorizedHandler: true, }), logoutAdmin: () => request>('/api/admin/auth/logout', { method: 'POST', }, { skipUnauthorizedHandler: true, }), }