fix(core): 基础资产「生成中」占位卡刷新后从后端认领恢复(出图本在 worker 跑,只是前端 loading 丢了)
问题:立绘/商品图出图是异步(worker),但 loading 占位只活在前端内存 genBusy + 当前页轮询 Promise,
刷新即丢 → 占位卡退回「待生成」,虽 worker 仍在跑、跑完手动刷新才见。
修复:
- 后端 GET /api/projects/{id}/pending-assets/ 返回在途出图任务(id/kind/label/status,读 request_payload)
- 前端进基础资产趴轮询该端点,据 (kind,label) 用 pendingHas 把对应占位卡置「生成中」;
在途数减少=有任务完成 → onRefreshProject 把占位卡换成真卡。离开该趴停止轮询。
tsc + py_compile + 21 测试通过。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from rest_framework.renderers import BaseRenderer
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from apps.ai.models import ModelConfig
|
from apps.ai.models import AITask, ModelConfig
|
||||||
from apps.ai.providers import TtsNotConfigured
|
from apps.ai.providers import TtsNotConfigured
|
||||||
from apps.ai.script_agent import stream_script_agent
|
from apps.ai.script_agent import stream_script_agent
|
||||||
from apps.ai.services import (
|
from apps.ai.services import (
|
||||||
@@ -238,6 +238,26 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
# 异步:出图在 worker 里跑,秒回 RESERVED 任务,前端轮询 /api/ai/generate-image/?ids=… 取结果后刷新项目
|
# 异步:出图在 worker 里跑,秒回 RESERVED 任务,前端轮询 /api/ai/generate-image/?ids=… 取结果后刷新项目
|
||||||
return Response({"task": {"id": str(task.id), "status": task.status}}, status=status.HTTP_202_ACCEPTED)
|
return Response({"task": {"id": str(task.id), "status": task.status}}, status=status.HTTP_202_ACCEPTED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"], url_path="pending-assets")
|
||||||
|
def pending_assets(self, request, pk=None):
|
||||||
|
"""本项目在途的基础资产出图任务(立绘/商品图/三视图)。前端进基础资产趴时据此重建
|
||||||
|
「生成中」占位卡 loading —— 出图在 worker 跑,刷新页面后内存态 genBusy 丢了,用它从后端认领恢复。"""
|
||||||
|
project = self.get_object()
|
||||||
|
inflight = [AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING]
|
||||||
|
image_types = [AITask.Type.PRODUCT_IMAGE, AITask.Type.PERSON_IMAGE, AITask.Type.SCENE_IMAGE]
|
||||||
|
tasks = AITask.objects.filter(project=project, task_type__in=image_types, status__in=inflight)
|
||||||
|
pending = []
|
||||||
|
for task in tasks:
|
||||||
|
payload = task.request_payload or {}
|
||||||
|
pending.append({
|
||||||
|
"id": str(task.id),
|
||||||
|
"kind": payload.get("kind") or "",
|
||||||
|
"label": payload.get("label") or "",
|
||||||
|
"is_triview": bool(payload.get("triview_of")),
|
||||||
|
"status": task.status,
|
||||||
|
})
|
||||||
|
return Response({"pending": pending})
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="poll-reviews")
|
@action(detail=True, methods=["post"], url_path="poll-reviews")
|
||||||
def poll_reviews(self, request, pk=None):
|
def poll_reviews(self, request, pk=None):
|
||||||
"""轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed)。前端基础资产趴定时调,刷新徽章。"""
|
"""轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed)。前端基础资产趴定时调,刷新徽章。"""
|
||||||
|
|||||||
@@ -207,6 +207,12 @@ export const api = {
|
|||||||
project(id: string) {
|
project(id: string) {
|
||||||
return request<Project>(`/api/projects/${id}/`);
|
return request<Project>(`/api/projects/${id}/`);
|
||||||
},
|
},
|
||||||
|
// 本项目在途的基础资产出图任务(刷新后据此重建「生成中」占位卡 loading)
|
||||||
|
pendingAssets(id: string) {
|
||||||
|
return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; status: string }> }>(
|
||||||
|
`/api/projects/${id}/pending-assets/`
|
||||||
|
);
|
||||||
|
},
|
||||||
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
|
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
|
||||||
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
|
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -523,6 +523,10 @@ export function PipelinePage(props: {
|
|||||||
|
|
||||||
// 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物)
|
// 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物)
|
||||||
const [genBusy, setGenBusy] = useState<Set<string>>(new Set());
|
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 isBusy = (k: string) => genBusy.has(k);
|
const isBusy = (k: string) => genBusy.has(k);
|
||||||
const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; });
|
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; });
|
const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; });
|
||||||
@@ -1849,6 +1853,30 @@ export function PipelinePage(props: {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeDot, viewStage, project.id]);
|
}, [activeDot, viewStage, project.id]);
|
||||||
|
|
||||||
|
// 流程步骤4 · 在基础资产趴轮询后端在途出图任务,重建「生成中」占位卡 loading(刷新后内存态丢失也能恢复);
|
||||||
|
// 在途数减少=有任务出图完成 → 刷新项目把占位卡换成真卡。离开该趴即停。
|
||||||
|
const prevPendingCountRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeDot !== 2 && viewStage !== 2) return;
|
||||||
|
let stopped = false;
|
||||||
|
let timer = 0;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.pendingAssets(project.id);
|
||||||
|
if (stopped) return;
|
||||||
|
const list = res.pending || [];
|
||||||
|
if (list.length < prevPendingCountRef.current) void onRefreshProject();
|
||||||
|
prevPendingCountRef.current = list.length;
|
||||||
|
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview })));
|
||||||
|
} catch {
|
||||||
|
/* 忽略,下一轮再试 */
|
||||||
|
}
|
||||||
|
if (!stopped) timer = window.setTimeout(tick, 4000);
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
return () => { stopped = true; window.clearTimeout(timer); };
|
||||||
|
}, [activeDot, viewStage, project.id, onRefreshProject]);
|
||||||
|
|
||||||
function goStage(n: number) {
|
function goStage(n: number) {
|
||||||
setViewStage(n);
|
setViewStage(n);
|
||||||
setNavigated(true);
|
setNavigated(true);
|
||||||
@@ -2214,7 +2242,7 @@ export function PipelinePage(props: {
|
|||||||
// 行39 · 商品三视图:一个商品组,candidate_assets = 各版本,adopted_asset = 采用版
|
// 行39 · 商品三视图:一个商品组,candidate_assets = 各版本,adopted_asset = 采用版
|
||||||
const productGroup = groupsByKind("product").find((g) => !isTriview(g)) || null;
|
const productGroup = groupsByKind("product").find((g) => !isTriview(g)) || null;
|
||||||
const productVersions = productGroup?.candidate_assets ?? [];
|
const productVersions = productGroup?.candidate_assets ?? [];
|
||||||
const triGenerating = isBusy("tri:product");
|
const triGenerating = isBusy("tri:product") || pendingHas("product", "");
|
||||||
const adoptedTriAsset = productGroup?.adopted_asset || "";
|
const adoptedTriAsset = productGroup?.adopted_asset || "";
|
||||||
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
||||||
const hasTriView = productVersions.length > 0;
|
const hasTriView = productVersions.length > 0;
|
||||||
@@ -2354,7 +2382,7 @@ export function PipelinePage(props: {
|
|||||||
{pendingTags.map((tag) => {
|
{pendingTags.map((tag) => {
|
||||||
const seedKey = `seed:${kind}:${tag}`;
|
const seedKey = `seed:${kind}:${tag}`;
|
||||||
const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag);
|
const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag);
|
||||||
const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`);
|
const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`) || pendingHas(kind, tag);
|
||||||
return (
|
return (
|
||||||
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
|
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
|
||||||
<div className="placeholder thumb-2">
|
<div className="placeholder thumb-2">
|
||||||
@@ -2379,7 +2407,7 @@ export function PipelinePage(props: {
|
|||||||
const mainUrl = groupMainUrl(grp);
|
const mainUrl = groupMainUrl(grp);
|
||||||
const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? "";
|
const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? "";
|
||||||
const entBK = `ent:${kind}:${entity.key}`;
|
const entBK = `ent:${kind}:${entity.key}`;
|
||||||
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`);
|
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || pendingHas(kind, entity.name);
|
||||||
const rs = kind === "person" && grp.adopted_asset ? assetReview(grp.adopted_asset) : "";
|
const rs = kind === "person" && grp.adopted_asset ? assetReview(grp.adopted_asset) : "";
|
||||||
return (
|
return (
|
||||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
||||||
|
|||||||
Reference in New Issue
Block a user