fix(core): 新建/删除项目后管线永久卡 loading — 旧详情请求晚到冲掉新项目

根因:action() 操作后 refreshProjectDetail() 闭包持旧 activeProjectId,
新建项目导航到 B 后,先发的旧项目详情 200 晚到把 projectDetail 冲回旧项目,
projectDetail.id≠activeProjectId → pipelineProject 变 null → 永久卡 loading(接口全 200 也卡)。

- refreshProjectDetail / 视频轮询 回写前用 activeProjectIdRef 校验 id 一致才 set,丢弃晚到旧响应
- 删除当前激活项目时清空 activeProjectId/projectDetail,杜绝拿已删 id 拉详情的 404 循环
- 详情拉取失败自动退避重试 3 次,404/403 即落错误态;仍失败给「重试/返回项目列表」,不再无限 loading

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-18 12:00:44 +08:00
co-authored by Claude Opus 4.8
parent 4c21d85ba1
commit 8e18d1c3c6
+67 -15
View File
@@ -109,6 +109,8 @@ export function App() {
const [unreadCount, setUnreadCount] = useState(0);
const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来
const [detailRetry, setDetailRetry] = useState(0); // 手动重试计数,变化即重新触发详情 useEffect
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
const [preferences, setPreferences] = useState<UserPreference | null>(null);
const [sessions, setSessions] = useState<LoginSession[]>([]);
@@ -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 (
<div className="app">
@@ -722,9 +762,21 @@ export function App() {
<div className="content">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 正在拉取项目数据</span></div>
<h1>{projectDetailError ? "项目加载失败" : "加载中…"}</h1>
<div className="sub">
<span className="mono">{projectDetailError ? "// 网络异常或服务繁忙" : "// 正在拉取项目数据"}</span>
</div>
</div>
{projectDetailError && (
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => setDetailRetry((n) => n + 1)}>
</button>
<button className="btn btn-ghost" type="button" onClick={() => navigate("projects")}>
</button>
</div>
)}
</div>
</div>
</main>