fix(pipeline): 基础资产页两个轮询空闲不停 → 空闲即停、开工即起

现象:停在基础资产页(尤其点「重新提取角色/场景」后)pending-assets 与
poll-reviews 一直轮询、刷屏。

根因:
- poll-reviews 的停止条件写成「只有先见过 processing 才停」,没有审核中资产时
  永远停不下来(8s 空转)。
- pending-assets 在该页无停止条件(4s 一直发)。
- pending-assets 把每渲染换新引用的 onRefreshProject 放进依赖 → 每渲染重订阅
  并立刻补发请求,越刷越密。

修复:
- pending-assets:一轮发现无在途出图 + genBusy 为空即停;genBusy 变化(开始/结束
  生成)时重订阅恢复。onRefreshProject 改走 ref,移出依赖。
- poll-reviews:一轮后端无 processing 即停;genBusy / reviewWake(手动送审)/
  pendingGenKey(出片完成→自动送审)任一变化时重订阅,把盾盯到终态再停。
- 终态(active/failed)在最后一轮非空响应即下发,停在空响应安全不丢更新。

空闲坐在基础资产页 = 零轮询;仅有真实生成/审核在途时才轮询且完事即停。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-26 13:16:48 +08:00
co-authored by Claude Opus 4.8
parent 669d9a683b
commit d26500bdbd
+22 -8
View File
@@ -714,12 +714,21 @@ export function PipelinePage(props: {
});
}
// 轮询用:onRefreshProject 每次 App 渲染都换新引用,若直接进 effect 依赖会让轮询每渲染重订阅+立刻重发请求
// (基础资产页「一直在轮询」的根因之一)。用 ref 稳住,effect 不再因它重跑。
const onRefreshProjectRef = useRef(onRefreshProject);
useEffect(() => { onRefreshProjectRef.current = onRefreshProject; });
// 审核轮询的「重启信号」:手动送审/一键送审后 +1,让已停的 poll-reviews 重新开跑去盯新提交的审核。
const [reviewWake, setReviewWake] = useState(0);
// 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物)
const [genBusy, setGenBusy] = useState<Set<string>>(new Set());
// 刷新后从后端「认领」在途出图任务:出图在 worker 跑、内存态 genBusy 丢了,据此重建占位卡 loading
const [pendingGen, setPendingGen] = useState<Array<{ kind: string; label: string; is_triview: boolean }>>([]);
const pendingHas = (kind: string, label: string) =>
pendingGen.some((p) => p.kind === kind && !p.is_triview && (p.label || "") === (label || ""));
// 在途出图集合的稳定指纹:集合变化(开工/出片完成)才变 → 给审核轮询当「重启信号」,
// 让真人出片后自动送审的盾能被盯到变绿(否则审核轮询可能在送审前就停了)。仅集合变才变,不会每轮抖动。
const pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}`).join("|"), [pendingGen]);
const isBusy = (k: string) => genBusy.has(k);
const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; });
const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; });
@@ -1071,6 +1080,7 @@ export function PipelinePage(props: {
const r = await api.submitAssetReview(id);
// 用后端真态,别再 `|| "processing"` 伪造审核中 —— 没真送出去就该回灰盾,而不是骗一个刷新即消失的「审核中」
setReviews((m) => ({ ...m, [id]: r.review_status || "" }));
if ((r.review_status || "") === "processing") setReviewWake((n) => n + 1); // 唤醒审核轮询去盯这条
} catch (e) {
// 送审没成(503=审核服务不可用等):如实提示,灰盾保持可重试,不再静默吞掉
onNotify?.("error", e instanceof Error && e.message ? e.message : "审核服务暂不可用,请稍后重试");
@@ -1083,7 +1093,6 @@ export function PipelinePage(props: {
useEffect(() => {
if (viewStage !== 2 && viewStage !== 3 && viewStage !== 4) return;
let alive = true;
let sawProcessing = false;
let timer = 0;
const tick = async () => {
if (document.hidden) return; // 标签页切走时不空跑审核轮询(纯读,回前台自然恢复)
@@ -1091,16 +1100,17 @@ export function PipelinePage(props: {
if (!alive) return;
const map = (r?.reviews || {}) as Record<string, string>;
if (Object.keys(map).length) {
sawProcessing = true;
setReviews((m) => ({ ...m, ...map }));
} else if (sawProcessing) {
window.clearInterval(timer); // 审核都终态了,停轮询省空跑(后端无 processing 资产时返回 {})
setReviews((m) => ({ ...m, ...map })); // 含终态(active绿/failed红):终态在这一轮就下发了
} else {
// 后端已无 processing 资产 → 没什么可等,停轮询(旧实现没见过 processing 时会 8s 空跑到永远)。
// 新生成的真人会自动送审、手动送审会 +reviewWake,届时本 effect 重订阅再开跑。
window.clearInterval(timer);
}
};
void tick();
timer = window.setInterval(tick, 8000);
return () => { alive = false; window.clearInterval(timer); };
}, [viewStage, project.id]);
}, [viewStage, project.id, genBusy, reviewWake, pendingGenKey]);
// 故事板有在制场(queued/running)时,后台每 5s 静默轮询驱动出图 + 刷新(对标视频段轮询)。
// 不占全局 loading → 单场重跑不会锁住其它场的按钮,可并行重跑多场。
useEffect(() => {
@@ -2376,9 +2386,11 @@ export function PipelinePage(props: {
const res = await api.pendingAssets(project.id);
if (stopped) return;
const list = res.pending || [];
if (list.length < prevPendingCountRef.current) void onRefreshProject();
if (list.length < prevPendingCountRef.current) void onRefreshProjectRef.current();
prevPendingCountRef.current = list.length;
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview })));
// 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。
if (list.length === 0 && genBusy.size === 0) { stopped = true; return; }
} catch {
/* 忽略,下一轮再试 */
}
@@ -2386,7 +2398,8 @@ export function PipelinePage(props: {
};
void tick();
return () => { stopped = true; window.clearTimeout(timer); };
}, [activeDot, viewStage, project.id, onRefreshProject]);
// genBusy 入依赖:开始/结束一次生成都会重订阅 → 空闲即停、开工即起。onRefreshProject 走 ref,不进依赖(否则每渲染重发)。
}, [activeDot, viewStage, project.id, genBusy]);
// 流程步骤4 · 刷新后认领在途「提取」:内存态 extractState 一刷新就丢,据后端 extract-status 重建「提取中」loading
// (和上面出图占位卡同思路)。已有轮询在跑则不重复起。进资产趴时检查一次。
@@ -3860,6 +3873,7 @@ export function PipelinePage(props: {
setReviewGate((g) => (g ? { ...g, submitting: true } : g));
const ids = [...new Set(reviewGate.blockers.filter((b) => b.review_status !== "processing" && b.asset_id).map((b) => b.asset_id))];
await Promise.all(ids.map((id) => api.submitAssetReview(id).then((r) => setReviews((m) => ({ ...m, [id]: r.review_status || "processing" }))).catch(() => undefined)));
if (ids.length) setReviewWake((n) => n + 1); // 唤醒审核轮询去盯刚送审的这些
setReviewGate(null);
onNotify?.("success", ids.length ? "已提交审核,请稍候待变绿(已过审)后再生成视频" : "素材正在审核中,请稍候待变绿后再生成");
}}