增加请求错误处理

This commit is contained in:
lsy 2025-05-03 20:12:52 +08:00
parent d3e0eddff7
commit 1336dc9081
8 changed files with 582 additions and 50 deletions

View File

@ -547,9 +547,23 @@ const ArticleFilter: React.FC<ArticleFilterProps> = ({ searchParams = {} }) => {
// 添加客户端标记变量,确保只在客户端渲染某些组件
const [isClient, setIsClient] = useState(false);
// 添加 AbortController 引用和组件挂载状态引用
const abortControllerRef = useRef<AbortController | null>(null);
const isMountedRef = useRef<boolean>(true);
// 组件挂载时设置客户端标记
useEffect(() => {
setIsClient(true);
// 组件卸载时的清理
return () => {
isMountedRef.current = false;
// 取消进行中的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// 处理searchParams确保我们有正确的参数格式
@ -692,6 +706,9 @@ const ArticleFilter: React.FC<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ searchParams = {} }) => {
filterParamsJson,
);
// 检查组件是否仍然挂载
if (!isMountedRef.current) return;
// 检查结果格式
if (!result || typeof result !== 'object') {
console.error("WASM返回结果格式错误");
@ -859,6 +940,9 @@ const ArticleFilter: React.FC<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ 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<ArticleFilterProps> = ({ searchParams = {} }) => {
total_pages: 0,
};
} finally {
setIsLoading(false);
// 检查组件是否仍然挂载
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
loadIndexData();
// 组件卸载时清理
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [wasmModule]);
// 检查activeFilters变化

View File

@ -148,14 +148,15 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
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<GitProjectCollectionProps> = ({
}
} finally {
// 如果组件已卸载,不继续更新状态
if (!isMountedRef.current) return;
setLoading(false);
setIsPageChanging(false);
if (isMountedRef.current) {
setLoading(false);
setIsPageChanging(false);
}
}
}, [platform, username, organization, token, perPage, url, projects.length]);

View File

@ -152,7 +152,7 @@ const navSelectorClassName = "mr-4";
</nav>
<!-- 使用自定义主题切换组件 -->
<div class="mt-1.5">
<div class="flex items-center">
<ThemeToggle className="group" />
</div>
</div>
@ -314,7 +314,7 @@ const navSelectorClassName = "mr-4";
>切换主题</span
>
<div
class="group relative w-7 h-7 mt-1 flex items-center justify-center"
class="group relative w-7 h-7 flex items-center justify-center"
>
<ThemeToggle
width={14}

View File

@ -23,6 +23,8 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, doubanId }) => {
const abortControllerRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const scrollDetectorRef = useRef<HTMLDivElement | null>(null);
// 添加一个 ref 来标记组件是否已挂载
const isMountedRef = useRef<boolean>(true);
// 使用ref来跟踪关键状态避免闭包问题
const stateRef = useRef({
@ -67,6 +69,12 @@ const MediaGrid: React.FC<MediaGridProps> = ({ 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<MediaGridProps> = ({ 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<MediaGridProps> = ({ 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<MediaGridProps> = ({ 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<MediaGridProps> = ({ type, doubanId }) => {
// 组件初始化和依赖变化时重置
useEffect(() => {
// 设置组件挂载状态
isMountedRef.current = true;
// 重置状态
setCurrentPage(1);
stateRef.current.currentPage = 1;
@ -295,6 +323,9 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, doubanId }) => {
// 清理函数
return () => {
// 标记组件已卸载
isMountedRef.current = false;
clearTimeout(timeoutId);
window.removeEventListener("scroll", handleScroll);

View File

@ -115,6 +115,10 @@ const Search: React.FC<SearchProps> = ({
const inlineSuggestionRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
// 添加 AbortController 引用以取消请求
const abortControllerRef = useRef<AbortController | null>(null);
// 添加组件挂载状态引用
const isMountedRef = useRef<boolean>(true);
// 辅助函数 - 从loadingState获取各种加载状态
const isLoading = loadingState.status === 'loading_search';
@ -135,8 +139,14 @@ const Search: React.FC<SearchProps> = ({
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<SearchProps> = ({
};
loadWasmModule();
// 组件卸载时清理
return () => {
isMountedRef.current = false;
};
}, []);
// 加载搜索索引
@ -153,10 +168,24 @@ const Search: React.FC<SearchProps> = ({
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<SearchProps> = ({
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<SearchProps> = ({
};
loadSearchIndex();
// 组件卸载时清理
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [wasmModule]);
// 监听窗口大小变化,确保内联建议位置正确
@ -264,6 +312,10 @@ const Search: React.FC<SearchProps> = ({
};
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<SearchProps> = ({
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<SearchProps> = ({
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<SearchProps> = ({
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<SearchProps> = ({
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<SearchProps> = ({
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<SearchProps> = ({
// 更新加载状态
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<SearchProps> = ({
);
};
// 组件卸载时清理
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 = (
<div className="relative [&_mark]:bg-yellow-200 dark:[&_mark]:bg-yellow-800">

View File

@ -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<WorldHeatmapProps> = ({ 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<WorldHeatmapProps> = ({ 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<WorldHeatmapProps> = ({ 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<WorldHeatmapProps> = ({ 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<WorldHeatmapProps> = ({ 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<WorldHeatmapProps> = ({ 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}`);
}
};

View File

@ -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 : '未知错误'

View File

@ -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: {