mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-04 11:30:58 +08:00
fix: 修复token过期未重新登录的问题 (#803)
* fix: 修复token过期未重新登录的问题 实现统一的401错误处理机制: - 创建httpClient封装fetch API,添加响应拦截器 - 401时自动清理localStorage和React状态 - 显示"请先登录"提示并延迟1.5秒后跳转登录页 - 保存当前URL到sessionStorage用于登录后返回 - 改造所有API调用使用httpClient统一处理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * fix: 添加401处理的单例保护防止并发竞态 问题: - 多个API同时返回401会导致多个通知叠加 - 多个style元素被添加到DOM造成内存泄漏 - 可能触发多次登录页跳转 解决方案: - 添加静态标志位 isHandling401 防止重复处理 - 第一个401触发完整处理流程 - 后续401直接抛出错误,避免重复操作 - 确保只显示一次通知和一次跳转 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * fix: 修复isHandling401标志永不重置的问题 问题: - isHandling401标志在401处理后永不重置 - 导致用户重新登录后,后续401会被静默忽略 - 页面刷新或取消重定向后标志仍为true 解决方案: - 在HttpClient中添加reset401Flag()公开方法 - 登录成功后调用reset401Flag()重置标志 - 页面加载时调用reset401Flag()确保新会话正常 影响范围: - web/src/lib/httpClient.ts: 添加reset方法和导出函数 - web/src/contexts/AuthContext.tsx: 在登录和页面加载时重置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * fix(auth): consume returnUrl after successful login (BLOCKING-1) 修复登录后未跳转回原页面的问题。 问题: - httpClient在401时保存returnUrl到sessionStorage - 但登录成功后没有读取和使用returnUrl - 导致用户登录后停留在登录页,无法回到原页面 修复: - 在loginAdmin、verifyOTP、completeRegistration三个登录方法中 - 添加returnUrl检查和跳转逻辑 - 登录成功后优先跳转到returnUrl,如果没有则使用默认页面 影响: - 用户token过期后重新登录,会自动返回之前访问的页面 - 提升用户体验,避免手动导航 测试场景: 1. 用户访问/traders → token过期 → 登录 → 自动回到/traders ✅ 2. 用户直接访问/login → 登录 → 跳转到默认页面(/dashboard或/traders) ✅ Related: BLOCKING-1 in PR #803 code review --------- Co-authored-by: sue <177699783@qq.com> Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<string, string> {
|
||||
export const api = {
|
||||
// AI交易员管理接口
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
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<any[]> {
|
||||
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<TraderInfo> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<any> {
|
||||
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<TraderInfo> {
|
||||
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<AIModel[]> {
|
||||
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<AIModel[]> {
|
||||
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<Exchange[]> {
|
||||
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<Exchange[]> {
|
||||
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<void> {
|
||||
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<any> {
|
||||
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<any[]> {
|
||||
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<any> {
|
||||
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<CompetitionData> {
|
||||
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<void> {
|
||||
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()
|
||||
},
|
||||
|
||||
212
web/src/lib/httpClient.ts
Normal file
212
web/src/lib/httpClient.ts
Normal file
@@ -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<Response> {
|
||||
// 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<string, string>): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post(
|
||||
url: string,
|
||||
body?: 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put(
|
||||
url: string,
|
||||
body?: 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete(
|
||||
url: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
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<Response> {
|
||||
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()
|
||||
Reference in New Issue
Block a user