mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 22:36:58 +08:00
fix: 修复token过期未重新登录的问题 (#803)
* fix: 修复token过期未重新登录的问题 实现统一的401错误处理机制: - 创建httpClient封装fetch API,添加响应拦截器 - 401时自动清理localStorage和React状态 - 显示"请先登录"提示并延迟1.5秒后跳转登录页 - 保存当前URL到sessionStorage用于登录后返回 - 改造所有API调用使用httpClient统一处理 Co-Authored-By: tinkle-community <tinklefund@gmail.com> * fix: 添加401处理的单例保护防止并发竞态 问题: - 多个API同时返回401会导致多个通知叠加 - 多个style元素被添加到DOM造成内存泄漏 - 可能触发多次登录页跳转 解决方案: - 添加静态标志位 isHandling401 防止重复处理 - 第一个401触发完整处理流程 - 后续401直接抛出错误,避免重复操作 - 确保只显示一次通知和一次跳转 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: 在登录和页面加载时重置 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:
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