diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index a659d3c..fdcaadc 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -109,6 +109,8 @@ export function App() { const [unreadCount, setUnreadCount] = useState(0); const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态 const [projectDetail, setProjectDetail] = useState(null); + const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来 + const [detailRetry, setDetailRetry] = useState(0); // 手动重试计数,变化即重新触发详情 useEffect const [exportResult, setExportResult] = useState(null); const [preferences, setPreferences] = useState(null); const [sessions, setSessions] = useState([]); @@ -269,18 +271,39 @@ export function App() { if (page !== "pipeline") setProjectDetail(null); return; } + // 已持有当前项目的完整详情(刚创建即落了全量数据 / 仍停在本项目):跳过这次拉取, + // 否则会再 setProjectDetail 触发重型 PipelinePage 重渲染(背景图重载),用户看到「刷新了一下」。 + if (projectDetail && projectDetail.id === activeProjectId) return; let cancelled = false; setExportResult(null); // 切项目/进管线时清空上个项目的导出态 - api - .project(activeProjectId) - .then((detail) => { - if (!cancelled) setProjectDetail(detail); - }) - .catch(() => undefined); + setProjectDetailError(false); + // 失败重试:网络抖动 / 偶发 5xx 时别永久卡 loading。退避重试几次,仍失败才落错误态(下方给重试按钮)。 + const fetchDetail = (attempt: number) => { + api + .project(activeProjectId) + .then((detail) => { + if (!cancelled) setProjectDetail(detail); + }) + .catch((error) => { + if (cancelled) return; + // 项目不存在(被删/无权限):重试也没用,直接落错误态给「返回项目列表」,别空转 + const status = error instanceof ApiError ? error.status : 0; + if (status === 404 || status === 403) { + setProjectDetailError(true); + return; + } + if (attempt < 3) { + setTimeout(() => { if (!cancelled) fetchDetail(attempt + 1); }, 800 * (attempt + 1)); + } else { + setProjectDetailError(true); + } + }); + }; + fetchDetail(0); return () => { cancelled = true; }; - }, [authed, page, activeProjectId]); + }, [authed, page, activeProjectId, detailRetry]); // 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。 // 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行 @@ -289,6 +312,11 @@ export function App() { useEffect(() => { projectDetailRef.current = projectDetail; }, [projectDetail]); + // 始终拿到「当前激活项目 id」的最新值(异步回写前用它校验,避免旧请求把刚切换/新建的项目详情冲掉) + const activeProjectIdRef = useRef(activeProjectId); + useEffect(() => { + activeProjectIdRef.current = activeProjectId; + }, [activeProjectId]); const pollVideosQuiet = useCallback(async () => { if (!activeProjectId) return; let detail = projectDetailRef.current; @@ -301,7 +329,8 @@ export function App() { if (active.length === 0) return; await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined))); const next = await api.project(activeProjectId).catch(() => null); - if (next) setProjectDetail(next); + // 晚到的旧项目轮询结果不要冲掉已切换的当前项目详情 + if (next && next.id === activeProjectIdRef.current) setProjectDetail(next); }, [activeProjectId]); // 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。 @@ -327,10 +356,13 @@ export function App() { } async function refreshProjectDetail() { - if (!activeProjectId) return; - const detail = await api.project(activeProjectId).catch(() => null); - // 写进 projectDetail;渲染处 pipelineProject 会校验 id 是否仍是当前激活项目,故后台旧请求写回也不会串台 - if (detail) setProjectDetail(detail); + // 取调用时的 id 去拉,但回写前再用 ref 校验「现在」激活的还是不是它 —— 否则新建/切项目后, + // 这个晚到的旧项目详情会把刚渲染的新项目详情冲掉,导致 projectDetail.id ≠ activeProjectId、 + // pipelineProject 变 null、页面永久卡 loading(接口全 200 也卡)。 + const targetId = activeProjectIdRef.current; + if (!targetId) return; + const detail = await api.project(targetId).catch(() => null); + if (detail && detail.id === activeProjectIdRef.current) setProjectDetail(detail); } // 防重复提交:已有操作在途时,后续 action 直接忽略(双击/连点/未及时置灰的按钮都安全)。 @@ -610,7 +642,14 @@ export function App() { navigate={navigate} onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")} openPipeline={(projectId) => navigate("pipeline", { projectId })} - onDelete={(projectId) => action(() => api.deleteProject(projectId), "项目已删除")} + onDelete={async (projectId) => { + const ok = await action(() => api.deleteProject(projectId), "项目已删除"); + // 删的是当前激活项目就清掉残留 id/详情,否则后续新建/进管线会拿着已删 id 去拉 → 404 / 卡 loading + if (ok !== null && projectId === activeProjectIdRef.current) { + setActiveProjectId(""); + setProjectDetail(null); + } + }} /> ); case "projectWizard": @@ -715,6 +754,7 @@ export function App() { // 这正是「站内点进去白屏、刷新就正常」的根因(刷新走 booting 等详情拉好才渲染,站内导航却拿轻量数据先渲染)。 const pipelineProject = projectDetail && projectDetail.id === activeProjectId ? projectDetail : null; // 详情还在拉取(刚切项目 / 首进管线):显示全屏加载占位,等完整详情到位再渲染,而不是拿轻量数据去崩。 + // 拉取多次仍失败则给重试 + 返回,避免「永久卡 loading 进不来」。 if (page === "pipeline" && activeProjectId && !pipelineProject) { return (
@@ -722,9 +762,21 @@ export function App() {
-

加载中…

-
// 正在拉取项目数据
+

{projectDetailError ? "项目加载失败" : "加载中…"}

+
+ {projectDetailError ? "// 网络异常或服务繁忙" : "// 正在拉取项目数据"} +
+ {projectDetailError && ( +
+ + +
+ )}