- 实现管理员登录和验证码验证功能 - 添加管理员登录流程,支持通过 Telegram 验证 - 实现登录挑战和会话管理,确保安全性 - 更新前端状态管理,处理登录和登出逻辑 - 增加系统配置管理,支持 Telegram 安全设置 - 新增数据库表以存储登录挑战和会话信息
345 lines
10 KiB
TypeScript
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,
|
|
}),
|
|
}
|