diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3b0045a..d615ea6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -79,8 +79,21 @@ export default function Sidebar() { }).catch(() => {}); }; fetchUnread(); - const timer = setInterval(fetchUnread, 60000); - return () => clearInterval(timer); + let timer: ReturnType | null = setInterval(fetchUnread, 60000); + // visibility 暂停:后台标签不再每 60s 拉文件夹统计 + const onVisibility = () => { + if (document.hidden) { + if (timer) { clearInterval(timer); timer = null; } + } else { + fetchUnread(); + timer = setInterval(fetchUnread, 60000); + } + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { + if (timer) clearInterval(timer); + document.removeEventListener("visibilitychange", onVisibility); + }; }, []); const { metas, diff --git a/frontend/src/components/ToolPanel/TranslationPanel.tsx b/frontend/src/components/ToolPanel/TranslationPanel.tsx index b276b94..cd78ff2 100644 --- a/frontend/src/components/ToolPanel/TranslationPanel.tsx +++ b/frontend/src/components/ToolPanel/TranslationPanel.tsx @@ -4,6 +4,7 @@ import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; import { paperApi, translateApi, tasksApi, type BilingualSegment } from '@/services/api'; +import { useToast } from '@/contexts/ToastContext'; pdfjs.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', @@ -66,6 +67,8 @@ export function TranslationPanel({ selectedText, paperId, paperArxivId, paperPdf const leftScrollRef = useRef(null); const rightScrollRef = useRef(null); + const pollRef = useRef | null>(null); // 翻译轮询定时器(卸载时清理) + const { toast } = useToast(); const [containerWidth, setContainerWidth] = useState(400); const scale = useMemo(() => { @@ -121,30 +124,52 @@ export function TranslationPanel({ selectedText, paperId, paperArxivId, paperPdf setTranslating(true); try { const { task_id } = await translateApi.startBilingualPdf(paperId, mode); - const poll = setInterval(async () => { - // 后端 /tasks/{id} 返回 global_tracker.to_dict():含 finished/success - const status = (await tasksApi.getStatus(task_id)) as unknown as { - finished: boolean; - success: boolean; - error: string | null; - }; - if (!status.finished) return; - clearInterval(poll); - if (status.success) { - const result = (await tasksApi.getResult(task_id)) as { - segments?: BilingualSegment[]; - pdf_url?: string; + const pollStart = Date.now(); + const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟超时兜底 + const MAX_ERRORS = 10; // 连续查询失败上限 + let consecutiveErrors = 0; + pollRef.current = setInterval(async () => { + // 超时兜底:任务挂起时不再无限轮询(此前 1800 次/小时泄漏) + if (Date.now() - pollStart > POLL_TIMEOUT_MS) { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + setTranslating(false); + toast('error', '翻译超时,请稍后重试'); + return; + } + try { + // 后端 /tasks/{id} 返回 global_tracker.to_dict():含 finished/success + const status = (await tasksApi.getStatus(task_id)) as unknown as { + finished: boolean; + success: boolean; + error: string | null; }; - if (mode === 'fast' && result.segments) { - setSegments(result.segments); - } else if (mode === 'layout' && result.pdf_url) { - setLayoutPdfUrl(result.pdf_url); + consecutiveErrors = 0; + if (!status.finished) return; + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + if (status.success) { + const result = (await tasksApi.getResult(task_id)) as { + segments?: BilingualSegment[]; + pdf_url?: string; + }; + if (mode === 'fast' && result.segments) { + setSegments(result.segments); + } else if (mode === 'layout' && result.pdf_url) { + setLayoutPdfUrl(result.pdf_url); + } + setViewMode('bilingual'); + } else { + toast('error', status.error || '翻译失败,请稍后重试'); + } + setTranslating(false); + } catch { + // 连续错误上限:网络持续异常时中断,不再静默空转 + consecutiveErrors += 1; + if (consecutiveErrors >= MAX_ERRORS) { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + setTranslating(false); + toast('error', '翻译状态查询持续失败,请稍后重试'); } - setViewMode('bilingual'); - } else { - alert(status.error || '翻译失败,请稍后重试'); } - setTranslating(false); }, 2000); } catch (err) { console.error('Failed to start translation:', err); @@ -170,6 +195,13 @@ export function TranslationPanel({ selectedText, paperId, paperArxivId, paperPdf return () => observer.disconnect(); }, []); + // 卸载时清理翻译轮询定时器,防泄漏 + useEffect(() => { + return () => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + }; + }, []); + const pdfUrl = useMemo(() => { const token = localStorage.getItem('auth_token') || ''; const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''; diff --git a/frontend/src/components/graph/CitationPanel.tsx b/frontend/src/components/graph/CitationPanel.tsx index 5aa64dc..0acd35a 100644 --- a/frontend/src/components/graph/CitationPanel.tsx +++ b/frontend/src/components/graph/CitationPanel.tsx @@ -481,7 +481,16 @@ function RichCitationListView({ data }: { data: CitationDetail }) { source_paper_title: data.paper_title, entries, }); + const pollStart = Date.now(); + const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟超时兜底 pollRef.current = setInterval(async () => { + if (Date.now() - pollStart > POLL_TIMEOUT_MS) { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = undefined; } + setImportTask(null); + setShowModal(false); + toast("warning", "导入超时,请稍后在论文列表确认结果"); + return; + } try { const status = await ingestApi.importStatus(task_id); setImportTask(status); @@ -501,7 +510,7 @@ function RichCitationListView({ data }: { data: CitationDetail }) { setShowModal(false); toast("error", "导入进度查询失败,请稍后在论文列表确认结果"); } - }, 1000); + }, 2000); } catch (err) { toast("error", `导入启动失败: ${String(err)}`); setShowModal(false); diff --git a/frontend/src/contexts/GlobalTaskContext.tsx b/frontend/src/contexts/GlobalTaskContext.tsx index 4b00a16..4b8a614 100644 --- a/frontend/src/contexts/GlobalTaskContext.tsx +++ b/frontend/src/contexts/GlobalTaskContext.tsx @@ -93,8 +93,23 @@ export function GlobalTaskProvider({ children }: { children: React.ReactNode }) useEffect(() => { fetchTasks(); intervalRef.current = setInterval(fetchTasks, POLL_IDLE); + + // visibility 暂停:标签页不可见时停止轮询(此前后台标签 360-1800 次/小时冗余请求) + const onVisibility = () => { + if (document.hidden) { + if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + } else { + // 恢复可见:立即拉一次 + 按当前状态重启轮询 + fetchTasks(); + const interval = hasRunningRef.current ? POLL_FAST : POLL_IDLE; + intervalRef.current = setInterval(fetchTasks, interval); + } + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { if (intervalRef.current) clearInterval(intervalRef.current); + document.removeEventListener("visibilitychange", onVisibility); }; }, [fetchTasks]); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8805f8b..df38701 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2,6 +2,7 @@ * PaperMind - API 服务层 * @author Color2333 */ +import { retryAsync } from "@/lib/errorHandler"; import type { SystemStatus, Topic, @@ -192,8 +193,10 @@ async function request(path: string, options: RequestInit = {}): Promise { return resp.json(); } +// GET 幂等,套 retryAsync(3 次指数退避,仅 NETWORK/SERVER/timeout 重试); +// POST/PATCH/PUT/DELETE 不套,避免重复写入 function get(path: string, opts?: { signal?: AbortSignal }) { - return request(path, { signal: opts?.signal }); + return retryAsync(() => request(path, { signal: opts?.signal })); } function post(path: string, body?: unknown, opts?: { signal?: AbortSignal }) {