diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 8d1e595..b21076f 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -11,7 +11,7 @@ from rest_framework.renderers import BaseRenderer from rest_framework.response import Response 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.script_agent import stream_script_agent from apps.ai.services import ( @@ -238,6 +238,26 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): # 异步:出图在 worker 里跑,秒回 RESERVED 任务,前端轮询 /api/ai/generate-image/?ids=… 取结果后刷新项目 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") def poll_reviews(self, request, pk=None): """轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed)。前端基础资产趴定时调,刷新徽章。""" diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index ea9c958..13afa54 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -207,6 +207,12 @@ export const api = { project(id: string) { return request(`/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 }) { return request("/api/projects/", { method: "POST", body: JSON.stringify(payload) }); }, diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 806899c..4aa8f5a 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -523,6 +523,10 @@ export function PipelinePage(props: { // 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物) const [genBusy, setGenBusy] = useState>(new Set()); + // 刷新后从后端「认领」在途出图任务:出图在 worker 跑、内存态 genBusy 丢了,据此重建占位卡 loading + const [pendingGen, setPendingGen] = useState>([]); + 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 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; }); @@ -1849,6 +1853,30 @@ export function PipelinePage(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [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) { setViewStage(n); setNavigated(true); @@ -2214,7 +2242,7 @@ export function PipelinePage(props: { // 行39 · 商品三视图:一个商品组,candidate_assets = 各版本,adopted_asset = 采用版 const productGroup = groupsByKind("product").find((g) => !isTriview(g)) || null; const productVersions = productGroup?.candidate_assets ?? []; - const triGenerating = isBusy("tri:product"); + const triGenerating = isBusy("tri:product") || pendingHas("product", ""); const adoptedTriAsset = productGroup?.adopted_asset || ""; const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset; const hasTriView = productVersions.length > 0; @@ -2354,7 +2382,7 @@ export function PipelinePage(props: { {pendingTags.map((tag) => { const seedKey = `seed:${kind}:${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 (
@@ -2379,7 +2407,7 @@ export function PipelinePage(props: { const mainUrl = groupMainUrl(grp); const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? ""; 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) : ""; return (