From 50be1de3aeb690b86e6a34f4e47bfd02b8c340fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E5=B0=8F=E8=88=9F?= Date: Sat, 30 May 2026 23:48:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20kill=20the=20"=E5=B7=B2=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E9=87=8D=E8=AF=95=E5=A4=84=E7=90=86=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?"=20success-toast=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix stopped the failure-toast spam but exposed a success-toast loop. handleRetryProject calls loadProjects, which sets loading=true and swaps the project list for HomePage's "正在加载项目列表" spinner — unmounting every ProjectCard. That reset the useRef auto-start guard, so on remount auto-start fired again → onRetry → handleRetryProject → loadProjects → unmount → ... an infinite loop, one "已开始重试处理项目" toast per cycle (and the flickering spinner in the report). Three cuts so the loop can't form: 1. Auto-start guard moved from useRef to a module-level Set, which survives the remount. Project ids are unique per import → once per session. 2. Silent auto-start no longer calls onRetry — it must never drive the parent's toast/reload path. Only user-clicked retries notify the parent. 3. handleRetryProject no longer re-issues retryProcessing (the card already sent the request); it just toasts once and refreshes. Frontend typecheck clean; production build OK. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/ProjectCard.tsx | 31 +++++++++++++++---------- frontend/src/pages/HomePage.tsx | 23 +++++++----------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/ProjectCard.tsx b/frontend/src/components/ProjectCard.tsx index adab4843..178bbd7e 100644 --- a/frontend/src/components/ProjectCard.tsx +++ b/frontend/src/components/ProjectCard.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect } from 'react' import { Card, Tag, Button, Space, Typography, Popconfirm, message, Tooltip } from 'antd' import { PlayCircleOutlined, DeleteOutlined, DownloadOutlined, ReloadOutlined, LoadingOutlined } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' @@ -49,6 +49,13 @@ if (typeof document !== 'undefined') { const { Text } = Typography +// Tracks which project ids have already had a best-effort auto-start, surviving +// component remounts (the list briefly unmounts while HomePage shows its +// loading spinner). A useRef would reset on every remount and let auto-start +// fire again, which created an infinite onRetry→loadProjects→remount loop. +// Project ids are unique per import, so once-per-session is exactly right. +const autoStartedProjectIds = new Set() + interface ProjectCardProps { project: Project onDelete: (id: string) => void @@ -244,20 +251,18 @@ const ProjectCard: React.FC = ({ project, onDelete, onRetry, o // 导致 effect 反复触发 → 对一个还没下载完的 B站项目疯狂 POST /process(返回 // 400 "Video file not found")→ 满屏「重试失败」。下载完成后后端会自动启动 // 流水线,所以这里只需做一次「尽力而为」的启动即可。 - const autoStartAttempted = useRef(false) - useEffect(() => { - autoStartAttempted.current = false - }, [project.id]) useEffect(() => { if ( project.status === 'pending' && !isDownloading && - !autoStartAttempted.current + !autoStartedProjectIds.has(project.id) ) { - autoStartAttempted.current = true - // Best-effort, one-shot. Uploads (file already present) start processing; - // B站 imports whose download isn't done yet return 400 here — that's fine, - // the backend auto-starts the pipeline when the download completes. + autoStartedProjectIds.add(project.id) + // Best-effort, one-shot per project. Uploads (file already present) start + // processing; B站 imports whose download isn't done yet return 400 here — + // that's fine, the backend auto-starts the pipeline when the download + // completes. Silent + no onRetry so this never drives the parent's + // toast/reload path. handleRetry({ silent: true }) } }, [project.status, project.id, isDownloading]) @@ -282,8 +287,10 @@ const ProjectCard: React.FC = ({ project, onDelete, onRetry, o } else { await projectApi.retryProcessing(project.id) } - // 移除重复的toast显示,让父组件统一处理 - if (onRetry) { + // 让父组件统一处理 toast / 刷新。但「静默自动启动」绝不能触发父组件, + // 否则会走 handleRetryProject → loadProjects → 列表重挂载 → 再次自动启动 + // 的死循环。只有用户手动点重试才通知父组件。 + if (onRetry && !opts?.silent) { onRetry(project.id) } } catch (error) { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index a72bc03b..30c451fa 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -89,23 +89,16 @@ const HomePage: React.FC = () => { } } - const handleRetryProject = async (projectId: string) => { + // 由 ProjectCard 在「用户手动点重试」且重试请求已成功后调用。 + // ProjectCard.handleRetry 已经发过 start/retryProcessing 请求,这里只负责 + // 提示 + 刷新列表,绝不能再发一次重试请求(会和卡片自身的请求叠加,并制造 + // loadProjects→重挂载→自动启动 的循环)。 + const handleRetryProject = async () => { + message.success('已开始重试处理项目') try { - // 查找项目状态 - const project = projects.find(p => p.id === projectId) - if (!project) { - message.error('项目不存在') - return - } - - // 统一使用retryProcessing API,它会自动处理视频文件不存在的情况 - await projectApi.retryProcessing(projectId) - message.success('已开始重试处理项目') - await loadProjects() } catch (error) { - message.error('重试失败,请稍后再试') - console.error('Retry project error:', error) + console.error('Refresh after retry error:', error) } } @@ -365,7 +358,7 @@ const HomePage: React.FC = () => { handleRetryProject(project.id)} + onRetry={() => handleRetryProject()} onClick={() => handleProjectCardClick(project)} />