Files
one-api-workers/frontend/src/api/client.ts
T
Jeff Gu bd76a89ba1 feat(auth): 新增系统设置功能
- 实现管理员登录和验证码验证功能
- 添加管理员登录流程,支持通过 Telegram 验证
- 实现登录挑战和会话管理,确保安全性
- 更新前端状态管理,处理登录和登出逻辑
- 增加系统配置管理,支持 Telegram 安全设置
- 新增数据库表以存储登录挑战和会话信息
2026-04-13 19:07:16 +08:00

345 lines
10 KiB
TypeScript

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<RequestInit> {
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<any> {
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, string | number | undefined>): 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<T>(
endpoint: string,
options: RequestInit = {},
behavior: RequestBehavior = {},
): Promise<T> {
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: <T>(endpoint: string) => request<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, data?: unknown) => request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T>(endpoint: string) => request<T>(endpoint, { method: 'DELETE' }),
// Channel APIs
getChannels: () => request<ApiResponse>('/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<ApiResponse>('/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<ApiResponse>('/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<ApiResponse>('/api/admin/pricing', { method: 'GET' }),
savePricing: (config: unknown) =>
request('/api/admin/pricing', {
method: 'POST',
body: JSON.stringify(config),
}),
getBillingConfig: () => request<ApiResponse<BillingConfig>>('/api/admin/billing/config', { method: 'GET' }),
saveBillingConfig: (config: BillingConfig) =>
request<ApiResponse<BillingConfig>>('/api/admin/billing/config', {
method: 'POST',
body: JSON.stringify(config),
}),
getSystemConfig: () => request<ApiResponse<SystemConfig>>('/api/admin/system/config', { method: 'GET' }),
saveSystemConfig: (config: SystemConfig) =>
request<ApiResponse<SystemConfig>>('/api/admin/system/config', {
method: 'POST',
body: JSON.stringify(config),
}),
sendTelegramTestMessage: (payload: Pick<SystemConfig['adminSecurity'], 'telegramBotToken' | 'telegramChatId'>) =>
request<ApiResponse<boolean>>('/api/admin/system/telegram/test', {
method: 'POST',
body: JSON.stringify(payload),
}),
// Analytics APIs
getAnalyticsOverview: (range: AnalyticsRange) =>
request<ApiResponse<AnalyticsOverviewData>>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }),
getAnalyticsTrend: (range: AnalyticsRange) =>
request<ApiResponse<AnalyticsTrendData>>(`/api/admin/analytics/trend?range=${encodeURIComponent(range)}`, { method: 'GET' }),
getAnalyticsBreakdown: (range: AnalyticsRange, dimension: AnalyticsBreakdownDimension) =>
request<ApiResponse<AnalyticsBreakdownData>>(
`/api/admin/analytics/breakdown?range=${encodeURIComponent(range)}&dimension=${encodeURIComponent(dimension)}`,
{ method: 'GET' }
),
getAnalyticsEvents: (range: AnalyticsRange, limit = 40) =>
request<ApiResponse<AnalyticsEventsData>>(
`/api/admin/analytics/events?range=${encodeURIComponent(range)}&limit=${encodeURIComponent(String(limit))}`,
{ method: 'GET' }
),
getUsageLogs: (filters: UsageLogFilters) =>
request<ApiResponse<UsageLogSearchData>>(
`/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<TestResponse> => {
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<ApiResponse>('/api/admin/channel', { method: 'GET' }),
startAdminLogin: (token: string) =>
request<ApiResponse<AdminLoginResponse>>('/api/admin/auth/login', {
method: 'POST',
body: JSON.stringify({ token }),
}, {
skipUnauthorizedHandler: true,
}),
verifyAdminLogin: (challengeId: string, code: string) =>
request<ApiResponse<AdminLoginResponse>>('/api/admin/auth/verify', {
method: 'POST',
body: JSON.stringify({ challengeId, code }),
}, {
skipUnauthorizedHandler: true,
}),
logoutAdmin: () =>
request<ApiResponse<boolean>>('/api/admin/auth/logout', {
method: 'POST',
}, {
skipUnauthorizedHandler: true,
}),
}