diff --git a/src/components/ArticleFilter.tsx b/src/components/ArticleFilter.tsx index 3a3232e..a5d16fd 100644 --- a/src/components/ArticleFilter.tsx +++ b/src/components/ArticleFilter.tsx @@ -547,9 +547,23 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { // 添加客户端标记变量,确保只在客户端渲染某些组件 const [isClient, setIsClient] = useState(false); + // 添加 AbortController 引用和组件挂载状态引用 + const abortControllerRef = useRef(null); + const isMountedRef = useRef(true); + // 组件挂载时设置客户端标记 useEffect(() => { setIsClient(true); + + // 组件卸载时的清理 + return () => { + isMountedRef.current = false; + + // 取消进行中的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; }, []); // 处理searchParams,确保我们有正确的参数格式 @@ -692,6 +706,9 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { const filterParamsJson = JSON.stringify(filterParams); const result = await wasmModule.ArticleFilterJS.filter_articles(filterParamsJson); + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + // 处理结果 if (!result || typeof result !== 'object') { console.error("[筛选] WASM返回结果格式错误"); @@ -721,10 +738,13 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { } } + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + // 检查并修复总数 const total = typeof result.total === 'number' ? result.total : articles.length; const totalPages = typeof result.total_pages === 'number' ? result.total_pages : - Math.ceil(total / filters.pageSize); + Math.ceil(total / filters.pageSize); // 更新状态时提供明确的默认值 setFilteredArticles(articles); @@ -732,10 +752,16 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { setTotalPages(totalPages || 1); } catch (error) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("[筛选] 应用筛选逻辑出错:", error); setError("筛选文章时出错,请刷新页面重试"); } finally { - setIsLoading(false); + // 检查组件是否仍然挂载 + if (isMountedRef.current) { + setIsLoading(false); + } } }; @@ -746,11 +772,22 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { const wasm = await import( "@/assets/wasm/article-filter/article_filter.js" ); + + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + if (typeof wasm.default === "function") { await wasm.default(); } + + // 再次检查组件是否仍然挂载 + if (!isMountedRef.current) return; + setWasmModule(wasm as unknown as ArticleFilterWasm); } catch (err) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("加载WASM模块失败:", err); setError("加载筛选模块失败,请刷新页面重试"); } @@ -766,38 +803,79 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { const loadIndexData = async () => { + // 取消之前的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 创建新的 AbortController + abortControllerRef.current = new AbortController(); + try { setIsLoading(true); - const response = await fetch("/index/filter_index.bin"); + const response = await fetch("/index/filter_index.bin", { + signal: abortControllerRef.current.signal + }); + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + if (!response.ok) { throw new Error(`获取筛选索引失败: ${response.statusText}`); } const indexData = await response.arrayBuffer(); + + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; // 初始化WASM模块 try { await wasmModule.ArticleFilterJS.init(new Uint8Array(indexData)); + + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; // 获取所有标签 const tags = (await wasmModule.ArticleFilterJS.get_all_tags()) || []; + + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + setAllAvailableTags(tags); // 初始加载时不依赖applyFilters函数,而是直接执行筛选逻辑 // 这避免了循环依赖问题 await initialLoadArticles(); + + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; setIsArticlesLoaded(true); } catch (parseError) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("解析筛选索引数据失败:", parseError); setError("索引文件存在但格式不正确,需要重新构建索引"); } } catch (fetchError) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + + // 如果是取消的请求,不显示错误 + if (fetchError instanceof Error && (fetchError.name === 'AbortError' || fetchError.message.includes('aborted'))) { + console.log('索引加载请求被取消:', fetchError.message); + return; + } + console.error("获取索引数据失败:", fetchError); setError("索引文件缺失或无法读取,请重新构建索引"); } finally { - setIsLoading(false); + // 检查组件是否仍然挂载 + if (isMountedRef.current) { + setIsLoading(false); + } } }; @@ -830,6 +908,9 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { filterParamsJson, ); + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + // 检查结果格式 if (!result || typeof result !== 'object') { console.error("WASM返回结果格式错误"); @@ -859,6 +940,9 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { } } + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + // 检查并修复总数 const total = typeof result.total === 'number' ? result.total : articles.length; const totalPages = typeof result.total_pages === 'number' ? result.total_pages : @@ -870,7 +954,7 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { setTotalPages(totalPages || 1); // 只有在非初始加载时才更新URL参数 - if (!skipUrlUpdate) { + if (!skipUrlUpdate && typeof window !== 'undefined') { // 初始化URL参数 const params = new URLSearchParams(); @@ -910,10 +994,16 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { return result; } catch (wasmError) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("WASM执行失败"); throw new Error(`WASM执行失败`); } } catch (error) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("初始加载文章出错"); setError("加载文章时出错,请刷新页面重试"); return { @@ -924,11 +1014,21 @@ const ArticleFilter: React.FC = ({ searchParams = {} }) => { total_pages: 0, }; } finally { - setIsLoading(false); + // 检查组件是否仍然挂载 + if (isMountedRef.current) { + setIsLoading(false); + } } }; loadIndexData(); + + // 组件卸载时清理 + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; }, [wasmModule]); // 检查activeFilters变化 diff --git a/src/components/GitProjectCollection.tsx b/src/components/GitProjectCollection.tsx index 71e5438..996361a 100644 --- a/src/components/GitProjectCollection.tsx +++ b/src/components/GitProjectCollection.tsx @@ -148,14 +148,15 @@ const GitProjectCollection: React.FC = ({ setProjects(data.projects || []); setPagination(data.pagination || { current: page, total: 1, hasNext: false, hasPrev: page > 1 }); } catch (err) { - // 如果是取消的请求,不显示错误 - if (err instanceof Error && err.name === 'AbortError') { - return; - } - // 如果组件已卸载,不继续更新状态 if (!isMountedRef.current) return; + // 如果是取消的请求,不显示错误 + if (err instanceof Error && (err.name === 'AbortError' || err.message.includes('aborted'))) { + console.log('请求被取消:', err.message); + return; + } + console.error('请求错误:', err); setError(err instanceof Error ? err.message : '未知错误'); // 保持之前的项目列表,避免清空显示 @@ -164,10 +165,10 @@ const GitProjectCollection: React.FC = ({ } } finally { // 如果组件已卸载,不继续更新状态 - if (!isMountedRef.current) return; - - setLoading(false); - setIsPageChanging(false); + if (isMountedRef.current) { + setLoading(false); + setIsPageChanging(false); + } } }, [platform, username, organization, token, perPage, url, projects.length]); diff --git a/src/components/Header.astro b/src/components/Header.astro index e982e38..ac84403 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -152,7 +152,7 @@ const navSelectorClassName = "mr-4"; -
+
@@ -314,7 +314,7 @@ const navSelectorClassName = "mr-4"; >切换主题
= ({ type, doubanId }) => { const abortControllerRef = useRef(null); const observerRef = useRef(null); const scrollDetectorRef = useRef(null); + // 添加一个 ref 来标记组件是否已挂载 + const isMountedRef = useRef(true); // 使用ref来跟踪关键状态,避免闭包问题 const stateRef = useRef({ @@ -67,6 +69,12 @@ const MediaGrid: React.FC = ({ type, doubanId }) => { `/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`, { signal: abortControllerRef.current.signal } ); + + // 检查组件是否已卸载,如果卸载则不继续处理 + if (!isMountedRef.current) { + return; + } + if (!response.ok) { // 解析响应内容,获取详细错误信息 let errorMessage = `获取${type === "movie" ? "电影" : "图书"}数据失败`; @@ -112,6 +120,11 @@ const MediaGrid: React.FC = ({ type, doubanId }) => { const data = await response.json(); + // 再次检查组件是否已卸载 + if (!isMountedRef.current) { + return; + } + if (data.items.length === 0) { // 如果返回的项目为空,则认为已经没有更多内容 setHasMoreContent(false); @@ -138,8 +151,17 @@ const MediaGrid: React.FC = ({ type, doubanId }) => { stateRef.current.hasMoreContent = newHasMoreContent; } } catch (error) { + // 检查组件是否已卸载 + if (!isMountedRef.current) { + return; + } + // 如果是取消的请求,不显示错误 if (error instanceof Error && error.name === 'AbortError') { + console.log('请求被取消', error.message); + // 如果是取消请求,重置加载状态但不显示错误 + setIsLoading(false); + stateRef.current.isLoading = false; return; } @@ -148,9 +170,12 @@ const MediaGrid: React.FC = ({ type, doubanId }) => { setItems([]); } } finally { - // 重置加载状态 - setIsLoading(false); - stateRef.current.isLoading = false; + // 检查组件是否已卸载 + if (isMountedRef.current) { + // 重置加载状态 + setIsLoading(false); + stateRef.current.isLoading = false; + } } }, [type, doubanId]); @@ -256,6 +281,9 @@ const MediaGrid: React.FC = ({ type, doubanId }) => { // 组件初始化和依赖变化时重置 useEffect(() => { + // 设置组件挂载状态 + isMountedRef.current = true; + // 重置状态 setCurrentPage(1); stateRef.current.currentPage = 1; @@ -295,6 +323,9 @@ const MediaGrid: React.FC = ({ type, doubanId }) => { // 清理函数 return () => { + // 标记组件已卸载 + isMountedRef.current = false; + clearTimeout(timeoutId); window.removeEventListener("scroll", handleScroll); diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 42be00d..6bbf830 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -115,6 +115,10 @@ const Search: React.FC = ({ const inlineSuggestionRef = useRef(null); const observerRef = useRef(null); const loadMoreRef = useRef(null); + // 添加 AbortController 引用以取消请求 + const abortControllerRef = useRef(null); + // 添加组件挂载状态引用 + const isMountedRef = useRef(true); // 辅助函数 - 从loadingState获取各种加载状态 const isLoading = loadingState.status === 'loading_search'; @@ -135,8 +139,14 @@ const Search: React.FC = ({ await wasm.default(); } + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + setWasmModule(wasm as unknown as SearchWasm); } catch (err) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("加载搜索WASM模块失败:", err); setLoadingState({ status: 'error', @@ -146,6 +156,11 @@ const Search: React.FC = ({ }; loadWasmModule(); + + // 组件卸载时清理 + return () => { + isMountedRef.current = false; + }; }, []); // 加载搜索索引 @@ -153,10 +168,24 @@ const Search: React.FC = ({ if (!wasmModule) return; const loadSearchIndex = async () => { + // 取消之前的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 创建新的 AbortController + abortControllerRef.current = new AbortController(); + try { setLoadingState(prev => ({ ...prev, status: 'loading_index' })); - const response = await fetch("/index/search_index.bin"); + const response = await fetch("/index/search_index.bin", { + signal: abortControllerRef.current.signal + }); + + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + if (!response.ok) { throw new Error(`获取搜索索引失败: ${response.statusText}`); } @@ -164,9 +193,21 @@ const Search: React.FC = ({ const indexBuffer = await response.arrayBuffer(); const data = new Uint8Array(indexBuffer); + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + setIndexData(data); setLoadingState(prev => ({ ...prev, status: 'success' })); } catch (err) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + + // 如果是取消的请求,不显示错误 + if (err instanceof Error && (err.name === 'AbortError' || err.message.includes('aborted'))) { + console.log('索引加载请求被取消:', err.message); + return; + } + console.error("搜索索引加载失败:", err); setLoadingState({ status: 'error', @@ -176,6 +217,13 @@ const Search: React.FC = ({ }; loadSearchIndex(); + + // 组件卸载时清理 + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; }, [wasmModule]); // 监听窗口大小变化,确保内联建议位置正确 @@ -264,6 +312,10 @@ const Search: React.FC = ({ }; const result = wasmModule.search_articles(indexData, JSON.stringify(req)); + + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + if (!result || result.trim() === "") { setSuggestions([]); setInlineSuggestion(prev => ({ ...prev, visible: false })); @@ -273,6 +325,9 @@ const Search: React.FC = ({ const searchResult = JSON.parse(result) as SearchResult; + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + // 确保有suggestions字段且是数组 if (!searchResult?.suggestions || !Array.isArray(searchResult.suggestions) || searchResult.suggestions.length === 0) { setSuggestions([]); @@ -305,6 +360,9 @@ const Search: React.FC = ({ setInlineSuggestion(prev => ({ ...prev, visible: false })); } } catch (err) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("获取内联建议失败:", err); setInlineSuggestion(prev => ({ ...prev, visible: false })); setSelectedSuggestionIndex(0); // 重置选中索引 @@ -524,6 +582,14 @@ const Search: React.FC = ({ return; } + // 取消之前的请求(虽然这是WASM调用,不是真正的网络请求,但保持一致性) + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 创建新的 AbortController + abortControllerRef.current = new AbortController(); + try { const page = isLoadMore ? currentPage : 1; @@ -544,6 +610,9 @@ const Search: React.FC = ({ const resultJson = wasmModule.search_articles(indexData, JSON.stringify(req)); + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + if (!resultJson || resultJson.trim() === "") { console.error("返回的搜索结果为空"); setLoadingState({ @@ -555,6 +624,9 @@ const Search: React.FC = ({ const result = JSON.parse(resultJson) as SearchResult; + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + // 预处理搜索结果 for (const item of result.items) { if (item.heading_tree) { @@ -584,6 +656,9 @@ const Search: React.FC = ({ // 更新加载状态 setLoadingState(prev => ({ ...prev, status: 'success' })); } catch (err) { + // 检查组件是否仍然挂载 + if (!isMountedRef.current) return; + console.error("搜索执行失败:", err); setLoadingState({ status: 'error', @@ -968,6 +1043,35 @@ const Search: React.FC = ({ ); }; + // 组件卸载时清理 + useEffect(() => { + // 设置组件已挂载状态 + isMountedRef.current = true; + + return () => { + // 标记组件已卸载 + isMountedRef.current = false; + + // 清理所有定时器 + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + // 取消所有进行中的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 清理观察器 + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, []); + // 渲染结束 const returnBlock = (
diff --git a/src/components/WorldHeatmap.tsx b/src/components/WorldHeatmap.tsx index 7450744..8980b12 100644 --- a/src/components/WorldHeatmap.tsx +++ b/src/components/WorldHeatmap.tsx @@ -19,6 +19,22 @@ import { } from "three"; import type { Side } from "three"; +// 为requestIdleCallback添加类型声明 +interface RequestIdleCallbackOptions { + timeout: number; +} + +interface Window { + requestIdleCallback?: ( + callback: (deadline: { + didTimeout: boolean; + timeRemaining: () => number; + }) => void, + opts?: RequestIdleCallbackOptions + ) => number; + cancelIdleCallback?: (handle: number) => void; +} + // 需要懒加载的模块 const loadControlsAndRenderers = () => Promise.all([ import("three/examples/jsm/controls/OrbitControls.js"), @@ -129,17 +145,32 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => { // 从公共目录加载地图数据 useEffect(() => { + // 创建 AbortController 用于在组件卸载时取消请求 + const controller = new AbortController(); + const signal = controller.signal; + const loadMapData = async () => { try { setMapLoading(true); setMapError(null); - // 从公共目录加载地图数据 + // 添加请求超时处理 + const timeout = setTimeout(() => controller.abort(), 30000); // 30秒超时 + + // 从公共目录加载地图数据,并使用 signal 和缓存控制 const [worldDataResponse, chinaDataResponse] = await Promise.all([ - fetch('/maps/world.zh.json'), - fetch('/maps/china.json') + fetch('/maps/world.zh.json', { + signal, + headers: { 'Cache-Control': 'no-cache' } + }), + fetch('/maps/china.json', { + signal, + headers: { 'Cache-Control': 'no-cache' } + }) ]); + clearTimeout(timeout); + if (!worldDataResponse.ok || !chinaDataResponse.ok) { throw new Error('无法获取地图数据'); } @@ -153,15 +184,23 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => { }); setMapLoading(false); - } catch (err) { - console.error("加载地图数据失败:", err); - const errorMessage = err instanceof Error ? err.message : String(err); - setMapError(`地图数据加载失败: ${errorMessage}`); + } catch (err: any) { + // 只有当请求不是被我们自己中断时才设置错误状态 + if (err.name !== 'AbortError') { + console.error("加载地图数据失败:", err); + const errorMessage = err instanceof Error ? err.message : String(err); + setMapError(`地图数据加载失败: ${errorMessage}`); + } setMapLoading(false); } }; loadMapData(); + + // 清理函数:组件卸载时中断请求 + return () => { + controller.abort(); + }; }, []); // 加载WASM模块 @@ -177,7 +216,7 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => { } setWasmModule(wasm as unknown as GeoWasmModule); setWasmError(null); - } catch (err) { + } catch (err: any) { console.error("加载WASM模块失败:", err); const errorMessage = err instanceof Error ? err.message : String(err); setWasmError(`WASM模块初始化失败: ${errorMessage}`); @@ -203,7 +242,7 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => { setGeoProcessor(geoProcessorInstance); setWasmReady(true); - } catch (error) { + } catch (error: any) { console.error("WASM数据处理失败:", error); const errorMessage = error instanceof Error ? error.message : String(error); setWasmError(`WASM数据处理失败: ${errorMessage}`); @@ -689,19 +728,50 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => { return null; }; - // 简化的动画循环函数 + // 优化的动画循环函数 const animate = () => { if (!sceneRef.current) return; - + + // 使用requestIdleCallback(如果可用)或setTimeout来限制更新频率 + // 这样可以减少CPU使用率和提高性能 + const scheduleNextFrame = () => { + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(() => { + if (sceneRef.current) { + sceneRef.current.animationId = requestAnimationFrame(animate); + } + }, { timeout: 100 }); + } else { + setTimeout(() => { + if (sceneRef.current) { + sceneRef.current.animationId = requestAnimationFrame(animate); + } + }, 16); // 约60fps的更新频率 + } + }; + + // 如果相机没有变化,可以降低渲染频率 + if (sceneRef.current.controls && + !sceneRef.current.controls.autoRotate && + sceneRef.current.lastCameraPosition && + sceneRef.current.camera.position.distanceTo(sceneRef.current.lastCameraPosition) < 0.001) { + // 相机位置没有明显变化,降低渲染频率 + scheduleNextFrame(); + return; + } + + // 保存当前相机位置 + sceneRef.current.lastCameraPosition = sceneRef.current.camera.position.clone(); + // 更新控制器 controls.update(); - + // 渲染 renderer.render(scene, camera); labelRenderer.render(scene, camera); - - // 请求下一帧 - sceneRef.current.animationId = requestAnimationFrame(animate); + + // 安排下一帧,使用优化的调度方式 + scheduleNextFrame(); }; // 处理窗口大小变化 @@ -751,8 +821,10 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => { // 开始动画 sceneRef.current.animationId = requestAnimationFrame(animate); - } catch (error) { + } catch (error: any) { console.error("Three.js初始化失败:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + setMapError(`3D地图初始化失败: ${errorMessage}`); } }; diff --git a/src/pages/api/douban.ts b/src/pages/api/douban.ts index de1df3a..70dcee4 100644 --- a/src/pages/api/douban.ts +++ b/src/pages/api/douban.ts @@ -26,19 +26,47 @@ function delay(ms: number) { // 带超时的 fetch 函数 async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number) { - const controller = new AbortController(); - const { signal } = controller; + // 检查是否已经提供了信号 + const existingSignal = options.signal; + // 创建我们自己的 AbortController 用于超时 + const timeoutController = new AbortController(); + const timeoutSignal = timeoutController.signal; + + // 设置超时 const timeout = setTimeout(() => { - controller.abort(); + timeoutController.abort(); }, timeoutMs); try { - const response = await fetch(url, { - ...options, - signal - }); - return response; + // 使用已有的信号和我们的超时信号 + if (existingSignal) { + // 如果已经取消了,直接抛出异常 + if (existingSignal.aborted) { + throw new DOMException('已被用户取消', 'AbortError'); + } + + // 创建一个监听器,当外部信号中止时,也中止我们的控制器 + const abortListener = () => timeoutController.abort(); + existingSignal.addEventListener('abort', abortListener); + + // 进行请求,但只使用我们的超时信号 + const response = await fetch(url, { + ...options, + signal: timeoutSignal + }); + + // 移除监听器 + existingSignal.removeEventListener('abort', abortListener); + + return response; + } else { + // 如果没有提供信号,只使用我们的超时信号 + return await fetch(url, { + ...options, + signal: timeoutSignal + }); + } } finally { clearTimeout(timeout); } @@ -314,6 +342,24 @@ export const GET: APIRoute = async ({ request }) => { }); } catch (error) { console.error(`尝试第 ${retries + 1}/${MAX_RETRIES + 1} 次失败:`, error); + + // 判断是否是请求被中止 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + console.warn('请求被中止:', error.message); + // 对于中止请求,我们可以直接返回404 + return new Response(JSON.stringify({ + error: '请求被中止', + message: '请求已被用户或服务器中止', + status: 499 // 使用499代表客户端中止请求 + }), { + status: 499, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, max-age=0' + } + }); + } + lastError = error instanceof Error ? error : new Error(String(error)); if (retries < MAX_RETRIES) { @@ -332,6 +378,21 @@ export const GET: APIRoute = async ({ request }) => { // 检查是否是常见错误类型并返回对应错误信息 const errorMessage = lastError?.message || '未知错误'; + // 检查是否是中止错误 + if (lastError && (lastError.name === 'AbortError' || errorMessage.includes('aborted'))) { + return new Response(JSON.stringify({ + error: '请求被中止', + message: '请求已被用户或系统中止', + status: 499 + }), { + status: 499, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, max-age=0' + } + }); + } + // 根据错误信息判断错误类型 if (errorMessage.includes('403') || errorMessage.includes('禁止访问') || errorMessage.includes('频繁')) { return new Response(JSON.stringify({ @@ -387,6 +448,22 @@ export const GET: APIRoute = async ({ request }) => { }); } catch (error) { console.error('处理请求时出错:', error); + + // 判断是否是中止错误 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + return new Response(JSON.stringify({ + error: '请求被中止', + message: '请求已被用户或系统中止', + status: 499 + }), { + status: 499, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, max-age=0' + } + }); + } + return new Response(JSON.stringify({ error: '获取豆瓣数据失败', message: error instanceof Error ? error.message : '未知错误' diff --git a/src/pages/api/git-projects.ts b/src/pages/api/git-projects.ts index 4ee732a..b13c5bd 100644 --- a/src/pages/api/git-projects.ts +++ b/src/pages/api/git-projects.ts @@ -87,9 +87,26 @@ export async function GET({ request }: APIContext) { headers }); } catch (error) { + // 检查是否为请求中止错误 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + return new Response(JSON.stringify({ + error: '请求被用户中止', + message: error.message, + type: 'abort' + }), { + status: 499, // 使用 499 状态码表示客户端关闭请求 + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + // 处理其他类型的错误 return new Response(JSON.stringify({ error: '处理请求错误', - message: error instanceof Error ? error.message : '未知错误' + message: error instanceof Error ? error.message : '未知错误', + type: 'server' }), { status: 500, headers: { @@ -111,6 +128,112 @@ export function OPTIONS() { }); } +// 使用带超时和重试的 fetch 函数 +async function fetchWithRetry(url: string, options: any, retries = 3, timeout = 10000) { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < retries; attempt++) { + try { + // 创建 AbortController 用于超时控制 + const controller = new AbortController(); + + // 如果原始请求已有 signal,保持追踪以便正确处理用户中止 + const originalSignal = options?.signal; + + // 设置超时定时器 + const timeoutId = setTimeout(() => { + controller.abort(`请求超时 (${timeout}ms)`); + }, timeout); + + // 添加超时的 signal 到请求选项 + const fetchOptions = { + ...options, + signal: controller.signal + }; + + // 如果有原始信号,监听其中止事件以便同步中止 + if (originalSignal) { + if (originalSignal.aborted) { + // 如果原始信号已经被中止,立即中止当前请求 + controller.abort('用户取消请求'); + clearTimeout(timeoutId); + throw new Error('用户取消请求'); + } + + // 监听原始信号的中止事件 + const abortHandler = () => { + controller.abort('用户取消请求'); + clearTimeout(timeoutId); + }; + + originalSignal.addEventListener('abort', abortHandler); + + // 确保在操作完成后清理事件监听器 + setTimeout(() => { + try { + originalSignal.removeEventListener('abort', abortHandler); + } catch (e) { + // 忽略可能出现的清理错误 + } + }, 0); + } + + try { + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + + // 检查是否为中止错误 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + // 确定中止原因 - 是超时还是用户请求 + const isTimeout = error.message.includes('timeout') || error.message.includes('超时'); + + if (isTimeout && attempt < retries - 1) { + // 如果是超时且还有重试次数,继续重试 + console.log(`请求超时,正在重试 (${attempt + 1}/${retries})...`); + lastError = error; + // 等待一段时间后重试 + await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))); + continue; + } else if (!isTimeout) { + // 如果是用户主动中止,直接抛出错误 + throw error; + } + } + + // 其他错误情况 + lastError = error as Error; + + // 增加重试间隔 + if (attempt < retries - 1) { + console.log(`请求失败,正在重试 (${attempt + 1}/${retries})...`); + await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))); + continue; + } + + throw lastError; + } + } catch (error) { + lastError = error as Error; + + // 如果是中止错误,直接抛出不再重试 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + throw error; + } + + // 最后一次尝试失败 + if (attempt === retries - 1) { + throw lastError; + } + } + } + + // 所有重试都失败了 + throw lastError || new Error('所有重试请求都失败了'); +} + async function fetchGithubProjects(username: string, organization: string, page: number, config: any) { const maxRetries = 3; let retryCount = 0; @@ -193,6 +316,11 @@ async function fetchGithubProjects(username: string, organization: string, page: } }; } catch (error) { + // 检查是否为中止错误 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + throw error; // 中止错误直接抛出,不重试 + } + retryCount++; if (retryCount >= maxRetries) { @@ -218,6 +346,7 @@ async function fetchGiteaProjects(username: string, organization: string, page: try { const perPage = config.perPage || 10; const giteaUrl = config.url; + const signal = config.signal; // 获取可能的 AbortSignal if (!giteaUrl) { throw new Error('Gitea URL 不存在'); @@ -232,12 +361,13 @@ async function fetchGiteaProjects(username: string, organization: string, page: apiUrl = `${giteaUrl}/api/v1/users/${config.username}/repos?page=${page}&per_page=${perPage}`; } - const response = await fetch(apiUrl, { + const response = await fetchWithRetry(apiUrl, { headers: { 'Accept': 'application/json', ...(config.token ? { 'Authorization': `token ${config.token}` } : {}) - } - }); + }, + signal // 传递 AbortSignal + }, 3, 15000); // 最多重试3次,每次超时15秒 if (!response.ok) { throw new Error(`Gitea API 请求失败: ${response.statusText}`); @@ -273,6 +403,13 @@ async function fetchGiteaProjects(username: string, organization: string, page: } }; } catch (error) { + // 检查是否为中止错误,将其向上传播 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + throw error; + } + + console.error('获取 Gitea 项目失败:', error); + return { projects: [], pagination: { @@ -288,6 +425,7 @@ async function fetchGiteaProjects(username: string, organization: string, page: async function fetchGiteeProjects(username: string, organization: string, page: number, config: any) { try { const perPage = config.perPage || 10; + const signal = config.signal; // 获取可能的 AbortSignal const giteeUsername = username || config.username; @@ -306,7 +444,9 @@ async function fetchGiteeProjects(username: string, organization: string, page: apiUrl += `&access_token=${config.token}`; } - const response = await fetch(apiUrl); + const response = await fetchWithRetry(apiUrl, { + signal // 传递 AbortSignal + }, 3, 15000); // 最多重试3次,每次超时15秒 if (!response.ok) { throw new Error(`Gitee API 请求失败: ${response.statusText}`); @@ -340,6 +480,13 @@ async function fetchGiteeProjects(username: string, organization: string, page: } }; } catch (error) { + // 检查是否为中止错误,将其向上传播 + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + throw error; + } + + console.error('获取 Gitee 项目失败:', error); + return { projects: [], pagination: {