diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 1930c587..9d1cfd1c 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react' import { getSystemConfig } from '../lib/config' +import { reset401Flag } from '../lib/httpClient' interface User { id: string @@ -58,6 +59,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(true) useEffect(() => { + // Reset 401 flag on page load to allow fresh 401 handling + reset401Flag() + // 先检查是否为管理员模式(使用带缓存的系统配置获取) getSystemConfig() .then(() => { @@ -85,6 +89,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) }, []) + // Listen for unauthorized events from httpClient (401 responses) + useEffect(() => { + const handleUnauthorized = () => { + console.log('Unauthorized event received - clearing auth state') + // Clear auth state when 401 is detected + setUser(null) + setToken(null) + // Note: localStorage cleanup is already done in httpClient + } + + window.addEventListener('unauthorized', handleUnauthorized) + + return () => { + window.removeEventListener('unauthorized', handleUnauthorized) + } + }, []) + const login = async (email: string, password: string) => { try { const response = await fetch('/api/login', { @@ -125,6 +146,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + const userInfo = { id: data.user_id || 'admin', email: data.email || 'admin@localhost', @@ -133,9 +157,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(userInfo) localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到仪表盘 - window.history.pushState({}, '', '/dashboard') - window.dispatchEvent(new PopStateEvent('popstate')) + + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到仪表盘 + window.history.pushState({}, '', '/dashboard') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true } } else { return { success: false, message: data.error || '登录失败' } @@ -199,6 +232,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + // 登录成功,保存token和用户信息 const userInfo = { id: data.user_id, email: data.email } setToken(data.token) @@ -206,9 +242,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到配置页面 - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到配置页面 + window.history.pushState({}, '', '/traders') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true, message: data.message } } else { @@ -232,6 +276,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + // 注册完成,自动登录 const userInfo = { id: data.user_id, email: data.email } setToken(data.token) @@ -239,9 +286,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到配置页面 - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到配置页面 + window.history.pushState({}, '', '/traders') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true, message: data.message } } else { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3d3eda69..a9390ea8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -13,6 +13,7 @@ import type { CompetitionData, } from '../types' import { CryptoService } from './crypto' +import { httpClient } from './httpClient' const API_BASE = '/api' @@ -33,51 +34,51 @@ function getAuthHeaders(): Record { export const api = { // AI交易员管理接口 async getTraders(): Promise { - const res = await fetch(`${API_BASE}/my-traders`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/my-traders`, getAuthHeaders()) if (!res.ok) throw new Error('获取trader列表失败') return res.json() }, // 获取公开的交易员列表(无需认证) async getPublicTraders(): Promise { - const res = await fetch(`${API_BASE}/traders`) + const res = await httpClient.get(`${API_BASE}/traders`) if (!res.ok) throw new Error('获取公开trader列表失败') return res.json() }, async createTrader(request: CreateTraderRequest): Promise { - const res = await fetch(`${API_BASE}/traders`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.post( + `${API_BASE}/traders`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('创建交易员失败') return res.json() }, async deleteTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}`, { - method: 'DELETE', - headers: getAuthHeaders(), - }) + const res = await httpClient.delete( + `${API_BASE}/traders/${traderId}`, + getAuthHeaders() + ) if (!res.ok) throw new Error('删除交易员失败') }, async startTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/start`, { - method: 'POST', - headers: getAuthHeaders(), - }) + const res = await httpClient.post( + `${API_BASE}/traders/${traderId}/start`, + undefined, + getAuthHeaders() + ) if (!res.ok) throw new Error('启动交易员失败') }, async stopTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, { - method: 'POST', - headers: getAuthHeaders(), - }) + const res = await httpClient.post( + `${API_BASE}/traders/${traderId}/stop`, + undefined, + getAuthHeaders() + ) if (!res.ok) throw new Error('停止交易员失败') }, @@ -85,18 +86,19 @@ export const api = { traderId: string, customPrompt: string ): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify({ custom_prompt: customPrompt }), - }) + const res = await httpClient.put( + `${API_BASE}/traders/${traderId}/prompt`, + { custom_prompt: customPrompt }, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新自定义策略失败') }, async getTraderConfig(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/config`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get( + `${API_BASE}/traders/${traderId}/config`, + getAuthHeaders() + ) if (!res.ok) throw new Error('获取交易员配置失败') return res.json() }, @@ -105,27 +107,25 @@ export const api = { traderId: string, request: CreateTraderRequest ): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.put( + `${API_BASE}/traders/${traderId}`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易员失败') return res.json() }, // AI模型配置接口 async getModelConfigs(): Promise { - const res = await fetch(`${API_BASE}/models`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/models`, getAuthHeaders()) if (!res.ok) throw new Error('获取模型配置失败') return res.json() }, // 获取系统支持的AI模型列表(无需认证) async getSupportedModels(): Promise { - const res = await fetch(`${API_BASE}/supported-models`) + const res = await httpClient.get(`${API_BASE}/supported-models`) if (!res.ok) throw new Error('获取支持的模型失败') return res.json() }, @@ -149,27 +149,25 @@ export const api = { ) // 发送加密数据 - const res = await fetch(`${API_BASE}/models`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(encryptedPayload), - }) + const res = await httpClient.put( + `${API_BASE}/models`, + encryptedPayload, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新模型配置失败') }, // 交易所配置接口 async getExchangeConfigs(): Promise { - const res = await fetch(`${API_BASE}/exchanges`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/exchanges`, getAuthHeaders()) if (!res.ok) throw new Error('获取交易所配置失败') return res.json() }, // 获取系统支持的交易所列表(无需认证) async getSupportedExchanges(): Promise { - const res = await fetch(`${API_BASE}/supported-exchanges`) + const res = await httpClient.get(`${API_BASE}/supported-exchanges`) if (!res.ok) throw new Error('获取支持的交易所失败') return res.json() }, @@ -177,11 +175,11 @@ export const api = { async updateExchangeConfigs( request: UpdateExchangeConfigRequest ): Promise { - const res = await fetch(`${API_BASE}/exchanges`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.put( + `${API_BASE}/exchanges`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易所配置失败') }, @@ -207,11 +205,11 @@ export const api = { ) // 发送加密数据 - const res = await fetch(`${API_BASE}/exchanges`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(encryptedPayload), - }) + const res = await httpClient.put( + `${API_BASE}/exchanges`, + encryptedPayload, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易所配置失败') }, @@ -220,9 +218,7 @@ export const api = { const url = traderId ? `${API_BASE}/status?trader_id=${traderId}` : `${API_BASE}/status` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取系统状态失败') return res.json() }, @@ -232,7 +228,7 @@ export const api = { const url = traderId ? `${API_BASE}/account?trader_id=${traderId}` : `${API_BASE}/account` - const res = await fetch(url, { + const res = await httpClient.request(url, { cache: 'no-store', headers: { ...getAuthHeaders(), @@ -250,9 +246,7 @@ export const api = { const url = traderId ? `${API_BASE}/positions?trader_id=${traderId}` : `${API_BASE}/positions` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取持仓列表失败') return res.json() }, @@ -262,9 +256,7 @@ export const api = { const url = traderId ? `${API_BASE}/decisions?trader_id=${traderId}` : `${API_BASE}/decisions` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取决策日志失败') return res.json() }, @@ -274,9 +266,7 @@ export const api = { const url = traderId ? `${API_BASE}/decisions/latest?trader_id=${traderId}` : `${API_BASE}/decisions/latest` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取最新决策失败') return res.json() }, @@ -286,9 +276,7 @@ export const api = { const url = traderId ? `${API_BASE}/statistics?trader_id=${traderId}` : `${API_BASE}/statistics` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取统计信息失败') return res.json() }, @@ -298,21 +286,15 @@ export const api = { const url = traderId ? `${API_BASE}/equity-history?trader_id=${traderId}` : `${API_BASE}/equity-history` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取历史数据失败') return res.json() }, // 批量获取多个交易员的历史数据(无需认证) async getEquityHistoryBatch(traderIds: string[]): Promise { - const res = await fetch(`${API_BASE}/equity-history-batch`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ trader_ids: traderIds }), + const res = await httpClient.post(`${API_BASE}/equity-history-batch`, { + trader_ids: traderIds, }) if (!res.ok) throw new Error('获取批量历史数据失败') return res.json() @@ -320,14 +302,14 @@ export const api = { // 获取前5名交易员数据(无需认证) async getTopTraders(): Promise { - const res = await fetch(`${API_BASE}/top-traders`) + const res = await httpClient.get(`${API_BASE}/top-traders`) if (!res.ok) throw new Error('获取前5名交易员失败') return res.json() }, // 获取公开交易员配置(无需认证) async getPublicTraderConfig(traderId: string): Promise { - const res = await fetch(`${API_BASE}/trader/${traderId}/config`) + const res = await httpClient.get(`${API_BASE}/trader/${traderId}/config`) if (!res.ok) throw new Error('获取公开交易员配置失败') return res.json() }, @@ -337,16 +319,14 @@ export const api = { const url = traderId ? `${API_BASE}/performance?trader_id=${traderId}` : `${API_BASE}/performance` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取AI学习数据失败') return res.json() }, // 获取竞赛数据(无需认证) async getCompetition(): Promise { - const res = await fetch(`${API_BASE}/competition`) + const res = await httpClient.get(`${API_BASE}/competition`) if (!res.ok) throw new Error('获取竞赛数据失败') return res.json() }, @@ -356,9 +336,10 @@ export const api = { coin_pool_url: string oi_top_url: string }> { - const res = await fetch(`${API_BASE}/user/signal-sources`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get( + `${API_BASE}/user/signal-sources`, + getAuthHeaders() + ) if (!res.ok) throw new Error('获取用户信号源配置失败') return res.json() }, @@ -367,14 +348,14 @@ export const api = { coinPoolUrl: string, oiTopUrl: string ): Promise { - const res = await fetch(`${API_BASE}/user/signal-sources`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ + 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('保存用户信号源配置失败') }, @@ -383,9 +364,7 @@ export const api = { public_ip: string message: string }> { - const res = await fetch(`${API_BASE}/server-ip`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders()) if (!res.ok) throw new Error('获取服务器IP失败') return res.json() }, diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts new file mode 100644 index 00000000..9097c416 --- /dev/null +++ b/web/src/lib/httpClient.ts @@ -0,0 +1,212 @@ +/** + * HTTP Client with unified error handling and 401 interception + * + * Features: + * - Unified fetch wrapper + * - Automatic 401 token expiration handling + * - Auth state cleanup on unauthorized + * - Automatic redirect to login page + */ + +export class HttpClient { + // Singleton flag to prevent duplicate 401 handling + private static isHandling401 = false + + /** + * Reset 401 handling flag (call after successful login) + */ + public reset401Flag(): void { + HttpClient.isHandling401 = false + } + + /** + * Show login required notification to user + */ + private showLoginRequiredNotification(): void { + // Create notification element + const notification = document.createElement('div') + notification.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #F0B90B 0%, #FCD535 100%); + color: #0B0E11; + padding: 16px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + z-index: 10000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + animation: slideDown 0.3s ease-out; + ` + notification.textContent = '⚠️ 登录已过期,请先登录' + + // Add slide down animation + const style = document.createElement('style') + style.textContent = ` + @keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + ` + document.head.appendChild(style) + + // Add to page + document.body.appendChild(notification) + + // Auto remove after animation + setTimeout(() => { + notification.style.animation = 'slideDown 0.3s ease-out reverse' + setTimeout(() => { + document.body.removeChild(notification) + document.head.removeChild(style) + }, 300) + }, 1800) + } + + /** + * Response interceptor - handles common HTTP errors + * + * @param response - Fetch Response object + * @returns Response if successful + * @throws Error with user-friendly message + */ + private async handleResponse(response: Response): Promise { + // Handle 401 Unauthorized - Token expired or invalid + if (response.status === 401) { + // Prevent duplicate 401 handling when multiple API calls fail simultaneously + if (HttpClient.isHandling401) { + throw new Error('登录已过期,请重新登录') + } + + // Set flag to prevent race conditions + HttpClient.isHandling401 = true + + // Clean up local storage + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + + // Notify global listeners (AuthContext will react to this) + window.dispatchEvent(new Event('unauthorized')) + + // Show user-friendly notification (only once) + this.showLoginRequiredNotification() + + // Delay redirect to let user see the notification + setTimeout(() => { + // 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) + } + + window.location.href = '/login' + } + // Note: No need to reset flag since we're redirecting + }, 1500) // 1.5秒延迟,让用户看到提示 + + throw new Error('登录已过期,请重新登录') + } + + // Handle other common errors + if (response.status === 403) { + throw new Error('没有权限访问此资源') + } + + if (response.status === 404) { + throw new Error('请求的资源不存在') + } + + if (response.status >= 500) { + throw new Error('服务器错误,请稍后重试') + } + + return response + } + + /** + * GET request + */ + async get(url: string, headers?: Record): Promise { + const response = await fetch(url, { + method: 'GET', + headers, + }) + return this.handleResponse(response) + } + + /** + * POST request + */ + async post( + url: string, + body?: any, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + return this.handleResponse(response) + } + + /** + * PUT request + */ + async put( + url: string, + body?: any, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + return this.handleResponse(response) + } + + /** + * DELETE request + */ + async delete( + url: string, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'DELETE', + headers, + }) + return this.handleResponse(response) + } + + /** + * Generic request method for custom configurations + */ + async request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, options) + return this.handleResponse(response) + } +} + +// Export singleton instance +export const httpClient = new HttpClient() + +// Export helper function to reset 401 flag +export const reset401Flag = () => httpClient.reset401Flag()