diff --git a/nkebao/src/api/interceptors.ts b/nkebao/src/api/interceptors.ts index 3b8da1b3..c07c4570 100644 --- a/nkebao/src/api/interceptors.ts +++ b/nkebao/src/api/interceptors.ts @@ -1,4 +1,4 @@ -import { refreshAuthToken, isTokenExpiringSoon, clearToken, requestDeduplicator } from './utils'; +import { refreshAuthToken, isTokenExpiringSoon, clearToken } from './utils'; // Token过期处理 export const handleTokenExpired = () => { @@ -6,9 +6,6 @@ export const handleTokenExpired = () => { // 清除本地存储 clearToken(); - // 清除所有待处理的请求 - requestDeduplicator.clear(); - // 跳转到登录页面 setTimeout(() => { window.location.href = '/login'; @@ -128,8 +125,6 @@ export const setupNetworkListener = () => { const handleOffline = () => { console.log('网络已断开'); - // 清除所有待处理的请求 - requestDeduplicator.clear(); showApiError(null, '网络连接已断开,请检查网络设置'); }; @@ -153,7 +148,5 @@ export const initInterceptors = () => { if (cleanupNetwork) { cleanupNetwork(); } - // 清理所有待处理的请求 - requestDeduplicator.clear(); }; }; \ No newline at end of file diff --git a/nkebao/src/api/request.ts b/nkebao/src/api/request.ts index 31f4ddd0..f152a431 100644 --- a/nkebao/src/api/request.ts +++ b/nkebao/src/api/request.ts @@ -1,6 +1,5 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import { requestInterceptor, responseInterceptor, errorInterceptor } from './interceptors'; -import { requestDeduplicator, throttle } from './utils'; // 创建axios实例 const request: AxiosInstance = axios.create({ @@ -49,52 +48,26 @@ request.interceptors.response.use( } ); -// 带节流和去重的GET请求 +// 封装GET请求 export const get = (url: string, config?: AxiosRequestConfig): Promise => { - return requestDeduplicator.execute( - 'GET', - url, - () => request.get(url, config), - config - ); + return request.get(url, config); }; -// 带节流和去重的POST请求 +// 封装POST请求 export const post = (url: string, data?: any, config?: AxiosRequestConfig): Promise => { - return requestDeduplicator.execute( - 'POST', - url, - () => request.post(url, data, config), - data - ); + return request.post(url, data, config); }; -// 带节流和去重的PUT请求 +// 封装PUT请求 export const put = (url: string, data?: any, config?: AxiosRequestConfig): Promise => { - return requestDeduplicator.execute( - 'PUT', - url, - () => request.put(url, data, config), - data - ); + return request.put(url, data, config); }; -// 带节流和去重的DELETE请求 +// 封装DELETE请求 export const del = (url: string, config?: AxiosRequestConfig): Promise => { - return requestDeduplicator.execute( - 'DELETE', - url, - () => request.delete(url, config), - config - ); + return request.delete(url, config); }; -// 带节流的请求函数(用于需要节流但不需要去重的场景) -export const throttledGet = throttle(get, 1000); -export const throttledPost = throttle(post, 1000); -export const throttledPut = throttle(put, 1000); -export const throttledDelete = throttle(del, 1000); - // 导出request实例 export { request }; export default request; \ No newline at end of file diff --git a/nkebao/src/api/utils.ts b/nkebao/src/api/utils.ts index a1c6c7f4..f53ddfd9 100644 --- a/nkebao/src/api/utils.ts +++ b/nkebao/src/api/utils.ts @@ -1,85 +1,5 @@ import { authApi } from './auth'; -// 节流函数 - 防止重复请求 -export const throttle = any>( - func: T, - delay: number = 1000 -): T => { - let lastCall = 0; - let lastCallTimer: NodeJS.Timeout | null = null; - - return ((...args: any[]) => { - const now = Date.now(); - - if (now - lastCall < delay) { - // 如果在节流时间内,取消之前的定时器并设置新的 - if (lastCallTimer) { - clearTimeout(lastCallTimer); - } - - lastCallTimer = setTimeout(() => { - lastCall = now; - func(...args); - }, delay - (now - lastCall)); - } else { - // 如果超过节流时间,直接执行 - lastCall = now; - func(...args); - } - }) as T; -}; - -// 请求去重管理器 -class RequestDeduplicator { - private pendingRequests: Map> = new Map(); - - // 生成请求的唯一键 - private generateKey(method: string, url: string, data?: any): string { - const dataStr = data ? JSON.stringify(data) : ''; - return `${method}:${url}:${dataStr}`; - } - - // 执行请求,如果相同请求正在进行中则返回已存在的Promise - async execute( - method: string, - url: string, - requestFn: () => Promise, - data?: any - ): Promise { - const key = this.generateKey(method, url, data); - - // 如果相同的请求正在进行中,返回已存在的Promise - if (this.pendingRequests.has(key)) { - console.log(`请求去重: ${key}`); - return this.pendingRequests.get(key)!; - } - - // 创建新的请求Promise - const requestPromise = requestFn().finally(() => { - // 请求完成后从pendingRequests中移除 - this.pendingRequests.delete(key); - }); - - // 将请求Promise存储到Map中 - this.pendingRequests.set(key, requestPromise); - - return requestPromise; - } - - // 清除所有待处理的请求 - clear() { - this.pendingRequests.clear(); - } - - // 获取当前待处理请求数量 - getPendingCount(): number { - return this.pendingRequests.size; - } -} - -// 创建全局请求去重实例 -export const requestDeduplicator = new RequestDeduplicator(); - // 设置token到localStorage export const setToken = (token: string) => { if (typeof window !== 'undefined') { diff --git a/nkebao/src/components/ThrottledButton.tsx b/nkebao/src/components/ThrottledButton.tsx index ed7fef58..ca08791d 100644 --- a/nkebao/src/components/ThrottledButton.tsx +++ b/nkebao/src/components/ThrottledButton.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import { useThrottledRequestWithLoading } from '../hooks/useThrottledRequest'; +import { + useThrottledRequestWithLoading, + useThrottledRequestWithError, + useRequestWithRetry, + useCancellableRequest +} from '../hooks/useThrottledRequest'; interface ThrottledButtonProps { onClick: () => Promise; @@ -7,6 +12,14 @@ interface ThrottledButtonProps { delay?: number; disabled?: boolean; className?: string; + variant?: 'throttle' | 'debounce' | 'retry' | 'cancellable'; + maxRetries?: number; + retryDelay?: number; + showLoadingText?: boolean; + loadingText?: string; + errorText?: string; + onSuccess?: (result: any) => void; + onError?: (error: any) => void; } export const ThrottledButton: React.FC = ({ @@ -14,17 +27,265 @@ export const ThrottledButton: React.FC = ({ children, delay = 1000, disabled = false, - className = '' + className = '', + variant = 'throttle', + maxRetries = 3, + retryDelay = 1000, + showLoadingText = true, + loadingText = '处理中...', + errorText, + onSuccess, + onError }) => { + // 处理请求结果 + const handleRequest = async () => { + try { + const result = await onClick(); + onSuccess?.(result); + } catch (error) { + onError?.(error); + } + }; + + // 根据variant渲染不同的按钮 + const renderButton = () => { + switch (variant) { + case 'retry': + return + {children} + ; + + case 'cancellable': + return + {children} + ; + + case 'debounce': + return + {children} + ; + + default: + return + {children} + ; + } + }; + + return renderButton(); +}; + +// 节流按钮内容组件 +const ThrottleButtonContent: React.FC<{ + onClick: () => Promise; + delay: number; + loadingText: string; + showLoadingText: boolean; + disabled: boolean; + className: string; + children: React.ReactNode; +}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, children }) => { const { throttledRequest, loading } = useThrottledRequestWithLoading(onClick, delay); - + + const getButtonText = () => { + return loading && showLoadingText ? loadingText : children; + }; + + const getButtonClassName = () => { + const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed'; + const variantClasses = loading + ? 'bg-gray-400 text-white cursor-not-allowed' + : 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400'; + + return `${baseClasses} ${variantClasses} ${className}`; + }; + return ( ); -}; \ No newline at end of file +}; + +// 防抖按钮内容组件 +const DebounceButtonContent: React.FC<{ + onClick: () => Promise; + delay: number; + loadingText: string; + showLoadingText: boolean; + disabled: boolean; + className: string; + errorText?: string; + children: React.ReactNode; +}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, errorText, children }) => { + const { debouncedRequest, loading, error } = useThrottledRequestWithError(onClick, delay); + + const getButtonText = () => { + return loading && showLoadingText ? loadingText : children; + }; + + const getButtonClassName = () => { + const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed'; + let variantClasses = ''; + if (loading) { + variantClasses = 'bg-gray-400 text-white cursor-not-allowed'; + } else if (error) { + variantClasses = 'bg-red-500 text-white hover:bg-red-600'; + } else { + variantClasses = 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400'; + } + + return `${baseClasses} ${variantClasses} ${className}`; + }; + + return ( +
+ + + {error && errorText && ( + {errorText} + )} +
+ ); +}; + +// 重试按钮内容组件 +const RetryButtonContent: React.FC<{ + onClick: () => Promise; + maxRetries: number; + retryDelay: number; + loadingText: string; + showLoadingText: boolean; + disabled: boolean; + className: string; + children: React.ReactNode; +}> = ({ onClick, maxRetries, retryDelay, loadingText, showLoadingText, disabled, className, children }) => { + const { requestWithRetry, loading, retryCount } = useRequestWithRetry(onClick, maxRetries, retryDelay); + + const getButtonText = () => { + if (loading) { + if (retryCount > 0) { + return `${loadingText} (重试 ${retryCount}/${maxRetries})`; + } + return showLoadingText ? loadingText : children; + } + return children; + }; + + const getButtonClassName = () => { + const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed'; + const variantClasses = loading + ? 'bg-gray-400 text-white cursor-not-allowed' + : 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400'; + + return `${baseClasses} ${variantClasses} ${className}`; + }; + + return ( + + ); +}; + +// 可取消按钮内容组件 +const CancellableButtonContent: React.FC<{ + onClick: () => Promise; + loadingText: string; + showLoadingText: boolean; + disabled: boolean; + className: string; + children: React.ReactNode; +}> = ({ onClick, loadingText, showLoadingText, disabled, className, children }) => { + const { cancellableRequest, loading, cancelRequest } = useCancellableRequest(onClick); + + const getButtonText = () => { + return loading && showLoadingText ? loadingText : children; + }; + + const getButtonClassName = () => { + const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed'; + const variantClasses = loading + ? 'bg-gray-400 text-white cursor-not-allowed' + : 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400'; + + return `${baseClasses} ${variantClasses} ${className}`; + }; + + return ( +
+ + + {loading && cancelRequest && ( + + )} +
+ ); +}; + +// 导出其他类型的按钮组件 +export const DebouncedButton: React.FC & { delay?: number }> = (props) => ( + +); + +export const RetryButton: React.FC & { maxRetries?: number; retryDelay?: number }> = (props) => ( + +); + +export const CancellableButton: React.FC> = (props) => ( + +); \ No newline at end of file diff --git a/nkebao/src/hooks/useThrottledRequest.ts b/nkebao/src/hooks/useThrottledRequest.ts index 289d3edc..e655ae09 100644 --- a/nkebao/src/hooks/useThrottledRequest.ts +++ b/nkebao/src/hooks/useThrottledRequest.ts @@ -1,5 +1,5 @@ -import { useCallback, useRef, useState } from 'react'; -import { requestDeduplicator } from '../api/utils'; +import { useCallback, useRef, useState, useEffect } from 'react'; +import { requestDeduplicator, requestCancelManager } from '../api/utils'; // 节流请求Hook export const useThrottledRequest = any>( @@ -94,4 +94,172 @@ export const useThrottledRequestWithLoading = Pro const throttledRequest = useThrottledRequest(requestWithLoading, delay); return { throttledRequest, loading }; +}; + +// 带错误处理的请求Hook +export const useRequestWithError = Promise>( + requestFn: T +) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const requestWithError = useCallback( + (async (...args: any[]) => { + setError(null); + setLoading(true); + + try { + const result = await requestFn(...args); + return result; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '请求失败'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }) as T, + [requestFn] + ); + + return { requestWithError, loading, error, clearError: () => setError(null) }; +}; + +// 带重试的请求Hook +export const useRequestWithRetry = Promise>( + requestFn: T, + maxRetries: number = 3, + retryDelay: number = 1000 +) => { + const [loading, setLoading] = useState(false); + const [retryCount, setRetryCount] = useState(0); + + const requestWithRetry = useCallback( + (async (...args: any[]) => { + setLoading(true); + setRetryCount(0); + + let lastError: any; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + setRetryCount(attempt); + const result = await requestFn(...args); + return result; + } catch (error) { + lastError = error; + + if (attempt === maxRetries) { + throw error; + } + + // 等待后重试 + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + + throw lastError; + }) as T, + [requestFn, maxRetries, retryDelay] + ); + + return { requestWithRetry, loading, retryCount }; +}; + +// 可取消的请求Hook +export const useCancellableRequest = Promise>( + requestFn: T +) => { + const [loading, setLoading] = useState(false); + const abortControllerRef = useRef(null); + + const cancellableRequest = useCallback( + (async (...args: any[]) => { + // 取消之前的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 创建新的AbortController + abortControllerRef.current = new AbortController(); + + setLoading(true); + + try { + const result = await requestFn(...args); + return result; + } finally { + setLoading(false); + abortControllerRef.current = null; + } + }) as T, + [requestFn] + ); + + const cancelRequest = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + setLoading(false); + abortControllerRef.current = null; + } + }, []); + + // 组件卸载时取消请求 + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + return { cancellableRequest, loading, cancelRequest }; +}; + +// 组合Hook:节流 + 加载状态 + 错误处理 +export const useThrottledRequestWithError = Promise>( + requestFn: T, + delay: number = 1000 +) => { + const { requestWithError, loading, error, clearError } = useRequestWithError(requestFn); + const throttledRequest = useThrottledRequest(requestWithError, delay); + + return { throttledRequest, loading, error, clearError }; +}; + +// 组合Hook:防抖 + 加载状态 + 错误处理 +export const useDebouncedRequestWithError = Promise>( + requestFn: T, + delay: number = 300 +) => { + const { requestWithError, loading, error, clearError } = useRequestWithError(requestFn); + const debouncedRequest = useDebouncedRequest(requestWithError, delay); + + return { debouncedRequest, loading, error, clearError }; +}; + +// 请求状态监控Hook +export const useRequestMonitor = () => { + const [pendingCount, setPendingCount] = useState(0); + + useEffect(() => { + const updatePendingCount = () => { + setPendingCount(requestDeduplicator.getPendingCount()); + }; + + // 初始更新 + updatePendingCount(); + + // 定期检查待处理请求数量 + const interval = setInterval(updatePendingCount, 100); + + return () => clearInterval(interval); + }, []); + + const cancelAllRequests = useCallback(() => { + requestCancelManager.cancelAllRequests(); + requestDeduplicator.clear(); + }, []); + + return { pendingCount, cancelAllRequests }; }; \ No newline at end of file diff --git a/nkebao/src/pages/devices/DeviceDetail.tsx b/nkebao/src/pages/devices/DeviceDetail.tsx index 40ac83d4..257c50f8 100644 --- a/nkebao/src/pages/devices/DeviceDetail.tsx +++ b/nkebao/src/pages/devices/DeviceDetail.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { smartGoBack, getBackButtonText } from '@/utils/navigation'; import { ChevronLeft, Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History, RefreshCw, Loader2 } from 'lucide-react'; import { devicesApi, fetchDeviceDetail, fetchDeviceRelatedAccounts, fetchDeviceHandleLogs, updateDeviceTaskConfig } from '@/api/devices'; import { useToast } from '@/components/ui/toast'; @@ -444,11 +443,11 @@ export default function DeviceDetail() { 无法加载ID为 "{id}" 的设备信息,请检查设备是否存在。 @@ -463,7 +462,7 @@ export default function DeviceDetail() {
@@ -714,7 +713,7 @@ export default function DeviceDetail() {
{account.nickname}
diff --git a/nkebao/src/pages/devices/Devices.tsx b/nkebao/src/pages/devices/Devices.tsx index 11705f0f..422ae292 100644 --- a/nkebao/src/pages/devices/Devices.tsx +++ b/nkebao/src/pages/devices/Devices.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { smartGoBack } from '@/utils/navigation'; import { ChevronLeft, Plus, Search, RefreshCw, QrCode, Smartphone, Loader2, AlertTriangle, Trash2, X } from 'lucide-react'; import { devicesApi } from '@/api'; import { useToast } from '@/components/ui/toast'; @@ -382,7 +381,7 @@ export default function Devices() {
diff --git a/nkebao/src/pages/plans/PlanDetail.tsx b/nkebao/src/pages/plans/PlanDetail.tsx index 27e159e9..902ba18e 100644 --- a/nkebao/src/pages/plans/PlanDetail.tsx +++ b/nkebao/src/pages/plans/PlanDetail.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { smartGoBack } from '@/utils/navigation'; import { ArrowLeft, Users, TrendingUp, Calendar, Settings, Play, Pause, Edit } from 'lucide-react'; interface PlanData { @@ -93,7 +92,7 @@ export default function PlanDetail() {
diff --git a/nkebao/src/utils/navigation.ts b/nkebao/src/utils/navigation.ts deleted file mode 100644 index 774deae1..00000000 --- a/nkebao/src/utils/navigation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NavigateFunction } from 'react-router-dom'; - -/** - * 智能返回上一页 - * 如果没有上一页历史记录,则返回到主页 - * @param navigate React Router的navigate函数 - * @param fallbackPath 默认返回路径,默认为'/' - */ -export const smartGoBack = (navigate: NavigateFunction, fallbackPath: string = '/') => { - // 检查是否有历史记录 - if (window.history.length > 1) { - // 尝试返回上一页 - navigate(-1); - } else { - // 如果没有历史记录,返回到主页 - navigate(fallbackPath); - } -}; - -/** - * 带确认的智能返回 - * @param navigate React Router的navigate函数 - * @param message 确认消息 - * @param fallbackPath 默认返回路径 - */ -export const smartGoBackWithConfirm = ( - navigate: NavigateFunction, - message: string = '确定要返回吗?未保存的内容将丢失。', - fallbackPath: string = '/' -) => { - if (window.confirm(message)) { - smartGoBack(navigate, fallbackPath); - } -}; - -/** - * 检查是否可以返回上一页 - * @returns boolean - */ -export const canGoBack = (): boolean => { - return window.history.length > 1; -}; - -/** - * 获取返回按钮文本 - * @param hasHistory 是否有历史记录 - * @returns 按钮文本 - */ -export const getBackButtonText = (hasHistory?: boolean): string => { - const canBack = hasHistory !== undefined ? hasHistory : canGoBack(); - return canBack ? '返回上一页' : '返回主页'; -}; \ No newline at end of file