refactor(web): redesign httpClient with axios and unified error handling (#1061)

* fix(web): remove duplicate PasswordChecklist in error block
- Remove duplicate PasswordChecklist component from error message area
- Keep only the real-time password validation checklist
- Error block now displays only the error message text
Bug was introduced in commit aa0bd93 (PR #872)
Co-Authored-By: tinkle-community <tinklefund@gmail.com>
* refactor(web): redesign httpClient with axios and unified error handling
Major refactoring to improve error handling architecture:
## Changes
### 1. HTTP Client Redesign (httpClient.ts)
- Replaced native fetch with axios for better interceptor support
- Implemented request/response interceptors for centralized error handling
- Added automatic Bearer token injection in request interceptor
- Network errors and system errors (404, 403, 500) now intercepted and shown via toast
- Only business logic errors (4xx except 401/403/404) returned to caller
- New ApiResponse<T> interface for type-safe responses
### 2. API Migration (api.ts)
- Migrated all 31 API methods from legacy fetch-style to new httpClient
- Updated pattern: from `res.ok/res.json()` to `result.success/result.data`
- Removed getAuthHeaders() helper (token now auto-injected)
- Added TypeScript generics for better type safety
### 3. Component Updates
- AuthContext.tsx: Updated register() to use new API
- TraderConfigModal.tsx: Migrated 3 API calls (config, templates, balance)
- RegisterPage.tsx: Simplified error display (error type handling now in API layer)
### 4. Removed Legacy Code
- Removed legacyHttpClient compatibility wrapper (~30 lines)
- Removed legacyRequest() method
- Clean separation: API layer handles all error classification
## Benefits
- Centralized error handling - no need to check network/system errors in components
- Better UX - automatic toast notifications for system errors
- Type safety - generic ApiResponse<T> provides compile-time checks
- Cleaner business components - only handle business logic errors
- Consistent error messages across the application
Co-Authored-By: tinkle-community <tinklefund@gmail.com>
---------
Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
Ember
2025-11-17 14:48:14 +08:00
committed by GitHub
parent b60383f22b
commit cdb7a6ba06
7 changed files with 428 additions and 346 deletions

View File

@@ -76,9 +76,9 @@ export function RegisterPage() {
setQrCodeURL(result.qrCodeURL || '')
setStep('setup-otp')
} else {
// Only business errors reach here (system/network errors shown via toast)
const msg = result.message || t('registrationFailed', language)
setError(msg)
toast.error(msg)
}
setLoading(false)
@@ -298,36 +298,7 @@ export function RegisterPage() {
color: 'var(--binance-red)',
}}
>
<div
className="mb-1"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('passwordRequirements', language)}
</div>
<PasswordChecklist
rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8}
specialCharsRegex={/[@#$%!&*?]/}
value={password}
valueAgain={confirmPassword}
messages={{
minLength: t('passwordRuleMinLength', language),
capital: t('passwordRuleUppercase', language),
lowercase: t('passwordRuleLowercase', language),
number: t('passwordRuleNumber', language),
specialChar: t('passwordRuleSpecial', language),
match: t('passwordRuleMatch', language),
}}
className="space-y-1"
onChange={(isValid) => setPasswordValid(isValid)}
/>
{error}
</div>
)}

View File

@@ -115,10 +115,22 @@ export function TraderConfigModal({
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await httpClient.get('/api/config')
const config = await response.json()
if (config.default_coins) {
setAvailableCoins(config.default_coins)
const result = await httpClient.get<{ default_coins?: string[] }>(
'/api/config'
)
if (result.success && result.data?.default_coins) {
setAvailableCoins(result.data.default_coins)
} else {
// 使用默认币种列表
setAvailableCoins([
'BTCUSDT',
'ETHUSDT',
'SOLUSDT',
'BNBUSDT',
'XRPUSDT',
'DOGEUSDT',
'ADAUSDT',
])
}
} catch (error) {
console.error('Failed to fetch config:', error)
@@ -141,10 +153,14 @@ export function TraderConfigModal({
useEffect(() => {
const fetchPromptTemplates = async () => {
try {
const response = await httpClient.get('/api/prompt-templates')
const data = await response.json()
if (data.templates) {
setPromptTemplates(data.templates)
const result = await httpClient.get<{ templates?: { name: string }[] }>(
'/api/prompt-templates'
)
if (result.success && result.data?.templates) {
setPromptTemplates(result.data.templates)
} else {
// 使用默认模板列表
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
}
} catch (error) {
console.error('Failed to fetch prompt templates:', error)
@@ -194,30 +210,26 @@ export function TraderConfigModal({
setBalanceFetchError('')
try {
const token = localStorage.getItem('auth_token')
if (!token) {
throw new Error('未登录,请先登录')
const result = await httpClient.get<{
total_equity?: number
balance?: number
}>(`/api/account?trader_id=${traderData.trader_id}`)
if (result.success && result.data) {
// total_equity = 当前账户净值(包含未实现盈亏)
// 这应该作为新的初始余额
const currentBalance =
result.data.total_equity || result.data.balance || 0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success('已获取当前余额')
} else {
throw new Error(result.message || '获取余额失败')
}
const response = await httpClient.get(
`/api/account?trader_id=${traderData.trader_id}`,
{
Authorization: `Bearer ${token}`,
}
)
const data = await response.json()
// total_equity = 当前账户净值(包含未实现盈亏)
// 这应该作为新的初始余额
const currentBalance = data.total_equity || data.balance || 0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success('已获取当前余额')
} catch (error) {
console.error('获取余额失败:', error)
setBalanceFetchError('获取余额失败,请检查网络连接')
toast.error('获取余额失败,请检查网络连接')
// Note: Network/system errors already shown via toast by httpClient
} finally {
setIsFetchingBalance(false)
}

View File

@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import { getSystemConfig } from '../lib/config'
import { reset401Flag } from '../lib/httpClient'
import { reset401Flag, httpClient } from '../lib/httpClient'
interface User {
id: string
@@ -183,39 +183,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password: string,
betaCode?: string
) => {
try {
const requestBody: {
email: string
password: string
beta_code?: string
} = { email, password }
if (betaCode) {
requestBody.beta_code = betaCode
const requestBody: {
email: string
password: string
beta_code?: string
} = { email, password }
if (betaCode) {
requestBody.beta_code = betaCode
}
const result = await httpClient.post<{
user_id: string
otp_secret: string
qr_code_url: string
message: string
}>('/api/register', requestBody)
if (result.success && result.data) {
return {
success: true,
userID: result.data.user_id,
otpSecret: result.data.otp_secret,
qrCodeURL: result.data.qr_code_url,
message: result.message || result.data.message,
}
}
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const data = await response.json()
if (response.ok) {
return {
success: true,
userID: data.user_id,
otpSecret: data.otp_secret,
qrCodeURL: data.qr_code_url,
message: data.message,
}
} else {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '注册失败,请重试' }
// Only business errors reach here (system/network errors were intercepted)
return {
success: false,
message: result.message || 'Registration failed',
}
}

View File

@@ -18,117 +18,92 @@ import { httpClient } from './httpClient'
const API_BASE = '/api'
// Helper function to get auth headers
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('auth_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
export const api = {
// AI交易员管理接口
async getTraders(): Promise<TraderInfo[]> {
const res = await httpClient.get(`${API_BASE}/my-traders`, getAuthHeaders())
if (!res.ok) throw new Error('获取trader列表失败')
return res.json()
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
if (!result.success) throw new Error('获取trader列表失败')
return result.data!
},
// 获取公开的交易员列表(无需认证)
async getPublicTraders(): Promise<any[]> {
const res = await httpClient.get(`${API_BASE}/traders`)
if (!res.ok) throw new Error('获取公开trader列表失败')
return res.json()
const result = await httpClient.get<any[]>(`${API_BASE}/traders`)
if (!result.success) throw new Error('获取公开trader列表失败')
return result.data!
},
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
const res = await httpClient.post(
const result = await httpClient.post<TraderInfo>(
`${API_BASE}/traders`,
request,
getAuthHeaders()
request
)
if (!res.ok) throw new Error('创建交易员失败')
return res.json()
if (!result.success) throw new Error('创建交易员失败')
return result.data!
},
async deleteTrader(traderId: string): Promise<void> {
const res = await httpClient.delete(
`${API_BASE}/traders/${traderId}`,
getAuthHeaders()
)
if (!res.ok) throw new Error('删除交易员失败')
const result = await httpClient.delete(`${API_BASE}/traders/${traderId}`)
if (!result.success) throw new Error('删除交易员失败')
},
async startTrader(traderId: string): Promise<void> {
const res = await httpClient.post(
`${API_BASE}/traders/${traderId}/start`,
undefined,
getAuthHeaders()
const result = await httpClient.post(
`${API_BASE}/traders/${traderId}/start`
)
if (!res.ok) throw new Error('启动交易员失败')
if (!result.success) throw new Error('启动交易员失败')
},
async stopTrader(traderId: string): Promise<void> {
const res = await httpClient.post(
`${API_BASE}/traders/${traderId}/stop`,
undefined,
getAuthHeaders()
)
if (!res.ok) throw new Error('停止交易员失败')
const result = await httpClient.post(`${API_BASE}/traders/${traderId}/stop`)
if (!result.success) throw new Error('停止交易员失败')
},
async updateTraderPrompt(
traderId: string,
customPrompt: string
): Promise<void> {
const res = await httpClient.put(
const result = await httpClient.put(
`${API_BASE}/traders/${traderId}/prompt`,
{ custom_prompt: customPrompt },
getAuthHeaders()
{ custom_prompt: customPrompt }
)
if (!res.ok) throw new Error('更新自定义策略失败')
if (!result.success) throw new Error('更新自定义策略失败')
},
async getTraderConfig(traderId: string): Promise<TraderConfigData> {
const res = await httpClient.get(
`${API_BASE}/traders/${traderId}/config`,
getAuthHeaders()
const result = await httpClient.get<TraderConfigData>(
`${API_BASE}/traders/${traderId}/config`
)
if (!res.ok) throw new Error('获取交易员配置失败')
return res.json()
if (!result.success) throw new Error('获取交易员配置失败')
return result.data!
},
async updateTrader(
traderId: string,
request: CreateTraderRequest
): Promise<TraderInfo> {
const res = await httpClient.put(
const result = await httpClient.put<TraderInfo>(
`${API_BASE}/traders/${traderId}`,
request,
getAuthHeaders()
request
)
if (!res.ok) throw new Error('更新交易员失败')
return res.json()
if (!result.success) throw new Error('更新交易员失败')
return result.data!
},
// AI模型配置接口
async getModelConfigs(): Promise<AIModel[]> {
const res = await httpClient.get(`${API_BASE}/models`, getAuthHeaders())
if (!res.ok) throw new Error('获取模型配置失败')
return res.json()
const result = await httpClient.get<AIModel[]>(`${API_BASE}/models`)
if (!result.success) throw new Error('获取模型配置失败')
return result.data!
},
// 获取系统支持的AI模型列表无需认证
async getSupportedModels(): Promise<AIModel[]> {
const res = await httpClient.get(`${API_BASE}/supported-models`)
if (!res.ok) throw new Error('获取支持的模型失败')
return res.json()
const result = await httpClient.get<AIModel[]>(
`${API_BASE}/supported-models`
)
if (!result.success) throw new Error('获取支持的模型失败')
return result.data!
},
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
@@ -150,37 +125,31 @@ export const api = {
)
// 发送加密数据
const res = await httpClient.put(
`${API_BASE}/models`,
encryptedPayload,
getAuthHeaders()
)
if (!res.ok) throw new Error('更新模型配置失败')
const result = await httpClient.put(`${API_BASE}/models`, encryptedPayload)
if (!result.success) throw new Error('更新模型配置失败')
},
// 交易所配置接口
async getExchangeConfigs(): Promise<Exchange[]> {
const res = await httpClient.get(`${API_BASE}/exchanges`, getAuthHeaders())
if (!res.ok) throw new Error('获取交易所配置失败')
return res.json()
const result = await httpClient.get<Exchange[]>(`${API_BASE}/exchanges`)
if (!result.success) throw new Error('获取交易所配置失败')
return result.data!
},
// 获取系统支持的交易所列表(无需认证)
async getSupportedExchanges(): Promise<Exchange[]> {
const res = await httpClient.get(`${API_BASE}/supported-exchanges`)
if (!res.ok) throw new Error('获取支持的交易所失败')
return res.json()
const result = await httpClient.get<Exchange[]>(
`${API_BASE}/supported-exchanges`
)
if (!result.success) throw new Error('获取支持的交易所失败')
return result.data!
},
async updateExchangeConfigs(
request: UpdateExchangeConfigRequest
): Promise<void> {
const res = await httpClient.put(
`${API_BASE}/exchanges`,
request,
getAuthHeaders()
)
if (!res.ok) throw new Error('更新交易所配置失败')
const result = await httpClient.put(`${API_BASE}/exchanges`, request)
if (!result.success) throw new Error('更新交易所配置失败')
},
// 使用加密传输更新交易所配置
@@ -205,12 +174,11 @@ export const api = {
)
// 发送加密数据
const res = await httpClient.put(
const result = await httpClient.put(
`${API_BASE}/exchanges`,
encryptedPayload,
getAuthHeaders()
encryptedPayload
)
if (!res.ok) throw new Error('更新交易所配置失败')
if (!result.success) throw new Error('更新交易所配置失败')
},
// 获取系统状态支持trader_id
@@ -218,9 +186,9 @@ export const api = {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`
const res = await httpClient.get(url, getAuthHeaders())
if (!res.ok) throw new Error('获取系统状态失败')
return res.json()
const result = await httpClient.get<SystemStatus>(url)
if (!result.success) throw new Error('获取系统状态失败')
return result.data!
},
// 获取账户信息支持trader_id
@@ -228,17 +196,10 @@ export const api = {
const url = traderId
? `${API_BASE}/account?trader_id=${traderId}`
: `${API_BASE}/account`
const res = await httpClient.request(url, {
cache: 'no-store',
headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache',
},
})
if (!res.ok) throw new Error('获取账户信息失败')
const data = await res.json()
console.log('Account data fetched:', data)
return data
const result = await httpClient.get<AccountInfo>(url)
if (!result.success) throw new Error('获取账户信息失败')
console.log('Account data fetched:', result.data)
return result.data!
},
// 获取持仓列表支持trader_id
@@ -246,9 +207,9 @@ export const api = {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`
const res = await httpClient.get(url, getAuthHeaders())
if (!res.ok) throw new Error('获取持仓列表失败')
return res.json()
const result = await httpClient.get<Position[]>(url)
if (!result.success) throw new Error('获取持仓列表失败')
return result.data!
},
// 获取决策日志支持trader_id
@@ -256,9 +217,9 @@ export const api = {
const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`
const res = await httpClient.get(url, getAuthHeaders())
if (!res.ok) throw new Error('获取决策日志失败')
return res.json()
const result = await httpClient.get<DecisionRecord[]>(url)
if (!result.success) throw new Error('获取决策日志失败')
return result.data!
},
// 获取最新决策支持trader_id和limit参数
@@ -272,12 +233,11 @@ export const api = {
}
params.append('limit', limit.toString())
const res = await httpClient.get(
`${API_BASE}/decisions/latest?${params}`,
getAuthHeaders()
const result = await httpClient.get<DecisionRecord[]>(
`${API_BASE}/decisions/latest?${params}`
)
if (!res.ok) throw new Error('获取最新决策失败')
return res.json()
if (!result.success) throw new Error('获取最新决策失败')
return result.data!
},
// 获取统计信息支持trader_id
@@ -285,9 +245,9 @@ export const api = {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`
const res = await httpClient.get(url, getAuthHeaders())
if (!res.ok) throw new Error('获取统计信息失败')
return res.json()
const result = await httpClient.get<Statistics>(url)
if (!result.success) throw new Error('获取统计信息失败')
return result.data!
},
// 获取收益率历史数据支持trader_id
@@ -295,32 +255,35 @@ export const api = {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`
const res = await httpClient.get(url, getAuthHeaders())
if (!res.ok) throw new Error('获取历史数据失败')
return res.json()
const result = await httpClient.get<any[]>(url)
if (!result.success) throw new Error('获取历史数据失败')
return result.data!
},
// 批量获取多个交易员的历史数据(无需认证)
async getEquityHistoryBatch(traderIds: string[]): Promise<any> {
const res = await httpClient.post(`${API_BASE}/equity-history-batch`, {
trader_ids: traderIds,
})
if (!res.ok) throw new Error('获取批量历史数据失败')
return res.json()
const result = await httpClient.post<any>(
`${API_BASE}/equity-history-batch`,
{ trader_ids: traderIds }
)
if (!result.success) throw new Error('获取批量历史数据失败')
return result.data!
},
// 获取前5名交易员数据无需认证
async getTopTraders(): Promise<any[]> {
const res = await httpClient.get(`${API_BASE}/top-traders`)
if (!res.ok) throw new Error('获取前5名交易员失败')
return res.json()
const result = await httpClient.get<any[]>(`${API_BASE}/top-traders`)
if (!result.success) throw new Error('获取前5名交易员失败')
return result.data!
},
// 获取公开交易员配置(无需认证)
async getPublicTraderConfig(traderId: string): Promise<any> {
const res = await httpClient.get(`${API_BASE}/trader/${traderId}/config`)
if (!res.ok) throw new Error('获取公开交易员配置失败')
return res.json()
const result = await httpClient.get<any>(
`${API_BASE}/trader/${traderId}/config`
)
if (!result.success) throw new Error('获取公开交易员配置失败')
return result.data!
},
// 获取AI学习表现分析支持trader_id
@@ -328,16 +291,18 @@ export const api = {
const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance`
const res = await httpClient.get(url, getAuthHeaders())
if (!res.ok) throw new Error('获取AI学习数据失败')
return res.json()
const result = await httpClient.get<any>(url)
if (!result.success) throw new Error('获取AI学习数据失败')
return result.data!
},
// 获取竞赛数据(无需认证)
async getCompetition(): Promise<CompetitionData> {
const res = await httpClient.get(`${API_BASE}/competition`)
if (!res.ok) throw new Error('获取竞赛数据失败')
return res.json()
const result = await httpClient.get<CompetitionData>(
`${API_BASE}/competition`
)
if (!result.success) throw new Error('获取竞赛数据失败')
return result.data!
},
// 用户信号源配置接口
@@ -345,27 +310,23 @@ export const api = {
coin_pool_url: string
oi_top_url: string
}> {
const res = await httpClient.get(
`${API_BASE}/user/signal-sources`,
getAuthHeaders()
)
if (!res.ok) throw new Error('获取用户信号源配置失败')
return res.json()
const result = await httpClient.get<{
coin_pool_url: string
oi_top_url: string
}>(`${API_BASE}/user/signal-sources`)
if (!result.success) throw new Error('获取用户信号源配置失败')
return result.data!
},
async saveUserSignalSource(
coinPoolUrl: string,
oiTopUrl: string
): Promise<void> {
const res = await httpClient.post(
`${API_BASE}/user/signal-sources`,
{
coin_pool_url: coinPoolUrl,
oi_top_url: oiTopUrl,
},
getAuthHeaders()
)
if (!res.ok) throw new Error('保存用户信号源配置失败')
const result = await httpClient.post(`${API_BASE}/user/signal-sources`, {
coin_pool_url: coinPoolUrl,
oi_top_url: oiTopUrl,
})
if (!result.success) throw new Error('保存用户信号源配置失败')
},
// 获取服务器IP需要认证用于白名单配置
@@ -373,8 +334,11 @@ export const api = {
public_ip: string
message: string
}> {
const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders())
if (!res.ok) throw new Error('获取服务器IP失败')
return res.json()
const result = await httpClient.get<{
public_ip: string
message: string
}>(`${API_BASE}/server-ip`)
if (!result.success) throw new Error('获取服务器IP失败')
return result.data!
},
}

View File

@@ -1,18 +1,47 @@
/**
* HTTP Client with unified error handling and 401 interception
* HTTP Client with Axios
*
* Features:
* - Unified fetch wrapper
* - Axios-based unified request wrapper
* - Automatic error interception and toast notifications
* - Network errors and system errors are intercepted and shown via toast
* - Only business logic errors are returned to the caller
* - Automatic 401 token expiration handling
* - Auth state cleanup on unauthorized
* - Automatic redirect to login page
* - Notification shown on login page after redirect
*/
import axios, { AxiosInstance, AxiosError, AxiosResponse } from 'axios'
import { toast } from 'sonner'
/**
* Business response format - only business errors reach the caller
*/
export interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
}
/**
* HTTP Client Class
*/
export class HttpClient {
// Singleton flag to prevent duplicate 401 handling
private axiosInstance: AxiosInstance
private static isHandling401 = false
constructor() {
// Create axios instance
this.axiosInstance = axios.create({
baseURL: '/',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// Setup interceptors
this.setupInterceptors()
}
/**
* Reset 401 handling flag (call after successful login)
*/
@@ -21,137 +50,212 @@ export class HttpClient {
}
/**
* Response interceptor - handles common HTTP errors
*
* @param response - Fetch Response object
* @returns Response if successful
* @throws Error with user-friendly message
* Setup request and response interceptors
*/
private async handleResponse(response: Response): Promise<Response> {
// Handle 401 Unauthorized - Token expired or invalid
if (response.status === 401) {
// Prevent duplicate 401 handling when multiple API calls fail simultaneously
private setupInterceptors(): void {
// Request interceptor - add auth token
this.axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor - handle errors
this.axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// Success response - pass through
return response
},
(error: AxiosError) => {
return this.handleError(error)
}
)
}
/**
* Handle different types of errors
* Network and system errors are intercepted and shown via toast
* Only business errors are returned to caller
*/
private async handleError(error: AxiosError): Promise<any> {
// Network error (no response from server)
if (!error.response) {
toast.error('Network error - Please check your connection', {
description: 'Unable to reach the server',
})
throw new Error('Network error')
}
const { status } = error.response as AxiosResponse<{
error?: string
message?: string
}>
// Handle 401 Unauthorized
if (status === 401) {
if (HttpClient.isHandling401) {
throw new Error('登录已过期,请重新登录')
throw new Error('Session expired')
}
// Set flag to prevent race conditions
HttpClient.isHandling401 = true
// Clean up local storage
// Clean up
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
// Notify global listeners (AuthContext will react to this)
// Notify global listeners
window.dispatchEvent(new Event('unauthorized'))
// Only redirect if not already on login page
if (!window.location.pathname.includes('/login')) {
// Save current location for post-login redirect
const returnUrl = window.location.pathname + window.location.search
if (returnUrl !== '/login' && returnUrl !== '/') {
sessionStorage.setItem('returnUrl', returnUrl)
}
// Mark that user came from 401 (login page will show notification)
sessionStorage.setItem('from401', 'true')
// Redirect immediately to login page
window.location.href = '/login'
// Return pending promise to prevent error from being caught by SWR/React
// The notification will be shown on the login page
return new Promise(() => {}) as Promise<Response>
// Return pending promise
return new Promise(() => {})
}
throw new Error('登录已过期,请重新登录')
throw new Error('Session expired')
}
// Handle other common errors
if (response.status === 403) {
throw new Error('没有权限访问此资源')
// Handle 403 Forbidden - system error
if (status === 403) {
toast.error('Permission Denied', {
description: 'You do not have permission to access this resource',
})
throw new Error('Permission denied')
}
if (response.status === 404) {
throw new Error('请求的资源不存在')
// Handle 404 Not Found - system error
if (status === 404) {
toast.error('API Not Found', {
description: 'The requested endpoint does not exist (404)',
})
throw new Error('API not found')
}
if (response.status >= 500) {
throw new Error('服务器错误,请稍后重试')
// Handle 500+ Server Error - system error
if (status >= 500) {
toast.error('Server Error', {
description: 'Please try again later or contact support',
})
throw new Error('Server error')
}
return response
// 4xx errors (except 401/403/404) are business logic errors
// Return them to the caller for handling
return Promise.reject(error)
}
/**
* Generic JSON request with standardized response
* System/network errors are already intercepted and shown via toast
* Only business errors are returned
*/
async request<T = any>(
url: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
data?: any
params?: any
headers?: Record<string, string>
} = {}
): Promise<ApiResponse<T>> {
try {
const response = await this.axiosInstance.request<T>({
url,
method: options.method || 'GET',
data: options.data,
params: options.params,
headers: options.headers,
})
// Success
return {
success: true,
data: response.data,
message: (response.data as any)?.message,
}
} catch (error) {
// If we get here, it's a business logic error (4xx except 401/403/404)
// System errors were already intercepted and toasted
if (axios.isAxiosError(error) && error.response) {
const errorData = error.response.data as any
return {
success: false,
message: errorData?.error || errorData?.message || 'Operation failed',
}
}
// Network error or other exception (already toasted)
throw error
}
}
/**
* GET request
*/
async get(url: string, headers?: Record<string, string>): Promise<Response> {
const response = await fetch(url, {
method: 'GET',
headers,
})
return this.handleResponse(response)
async get<T = any>(
url: string,
params?: any,
headers?: Record<string, string>
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'GET', params, headers })
}
/**
* POST request
*/
async post(
async post<T = any>(
url: string,
body?: any,
data?: any,
headers?: Record<string, string>
): Promise<Response> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
})
return this.handleResponse(response)
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'POST', data, headers })
}
/**
* PUT request
*/
async put(
async put<T = any>(
url: string,
body?: any,
data?: any,
headers?: Record<string, string>
): Promise<Response> {
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
})
return this.handleResponse(response)
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'PUT', data, headers })
}
/**
* DELETE request
*/
async delete(
async delete<T = any>(
url: string,
headers?: Record<string, string>
): Promise<Response> {
const response = await fetch(url, {
method: 'DELETE',
headers,
})
return this.handleResponse(response)
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'DELETE', headers })
}
/**
* Generic request method for custom configurations
* PATCH request
*/
async request(url: string, options: RequestInit = {}): Promise<Response> {
const response = await fetch(url, options)
return this.handleResponse(response)
async patch<T = any>(
url: string,
data?: any,
headers?: Record<string, string>
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'PATCH', data, headers })
}
}