fix: 电商测试清单一轮 bug 修复(视频项目角色/场景 + 图片工作台 + 视频收口)

视频项目(ZWQ):
- 演员工作台保存后「我的演员」仍 0 个:保存时加入资产库(in_library=True)
- 资产详情「应用当前角色」自动送审(不再要手动点灰盾)
- 「我的演员」卡 + 待生成 seed 卡补删除入口(垃圾桶,两步确认)
- 新增场景未生成就刷新会丢:sceneDrafts 按项目持久化 localStorage
- 场景 AI 生成强制叠加「无真人」约束,避免出真人致视频被火山拒
- 进入项目默认落到当前阶段(不再恒显第 1 阶段)
- 视频已出但进度卡 3/4:submit/poll/adopt 幂等补推 current_stage→VIDEO,
  否则 settle_video_completion 因阶段守卫直接返回、永不收口 completed

图片工作台(PMC):
- 生图走 action 全局单飞锁致并发重跑/多平台套图被拒成「失败/少出图」:
  action 加 concurrent 选项,生图不占单飞锁
- 切模块再切回状态丢/数量少:每批各自落盘 pendingIds(onSubmitted 回传),
  切回时全部批次各自续轮询

附:含一处早前未提交的图片创作改动(选火山+传参考图强制切 gpt-image-2 + gen 气泡溢出 CSS)。
新增/通过测试:AttachOfficialModel、商品图删除、视频卡阶段收口、参考图强切模型。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-27 15:11:29 +08:00
co-authored by Claude Opus 4.8
parent ce7eaefe1d
commit 8f32ad69d3
9 changed files with 253 additions and 97 deletions
+46 -4
View File
@@ -583,6 +583,20 @@ export function PipelinePage(props: {
if (rm) undoTagEdit(rm.id); else queueRemoveTag(kind, value);
}
const tagEditVerb = (e: TagEdit) => `${e.op === "add" ? "添加" : "删除"}${e.kind === "cast" ? "人物" : "场景"}${e.value}`;
// 直接删除一个「待生成」seed 标签(还没出参考图的角色/场景):立即从 cast/scenes 移除并落库,
// 不走「整体重写」的待删队列——给 seed 卡一个能用的删除入口(ZWQ#9)。assetKind: person↔cast / scene↔scene。
function deleteSeedTag(assetKind: "person" | "scene", tag: string) {
if (assetKind === "person") {
const next = castTags.filter((t) => t !== tag);
setCastTags(next);
void onSaveProjectMeta?.({ cast: next });
} else {
const next = sceneTags.filter((t) => t !== tag);
setSceneTags(next);
void onSaveProjectMeta?.({ scenes: next });
}
}
const [seedDelArmed, setSeedDelArmed] = useState<string | null>(null);
// 行34 · 「添加分镜」插入的本地空白可编辑卡(尚未落库;失焦有内容才真正 onAddShot)
type DraftShot = { id: string; afterId: string | null; narration: string; visual: string };
const [draftShots, setDraftShots] = useState<DraftShot[]>([]);
@@ -598,7 +612,14 @@ export function PipelinePage(props: {
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
// 新增场景:先落「占位草稿卡」(标题 + 可编辑提示词),用户编辑完点「AI 生成」才真正出图;
// 出图成功后从草稿列表移除(生成结果会作为实体卡出现在下方)。不直接生成,避免一点就出一张默认图。
const [sceneDrafts, setSceneDrafts] = useState<Array<{ id: string; title: string; prompt: string }>>([]);
// 持久化到 localStorage(按项目隔离):新增场景没点 AI 生成就刷新页面也不会丢(ZWQ#10)。
const sceneDraftsKey = `airshelf:scenedrafts:${project.id}`;
const [sceneDrafts, setSceneDrafts] = useState<Array<{ id: string; title: string; prompt: string }>>(() => {
try { const raw = localStorage.getItem(sceneDraftsKey); return raw ? (JSON.parse(raw) as Array<{ id: string; title: string; prompt: string }>) : []; } catch { return []; }
});
useEffect(() => {
try { localStorage.setItem(sceneDraftsKey, JSON.stringify(sceneDrafts)); } catch { /* localStorage 不可用时静默降级 */ }
}, [sceneDrafts, sceneDraftsKey]);
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
const [triPanelOpen, setTriPanelOpen] = useState(false);
@@ -1067,7 +1088,9 @@ export function PipelinePage(props: {
// V1 雪藏拼接导出:已完成项目落到第 4 阶段(视频),不落到被藏的第 5(V2 恢复改回 5)
const projectStage = project.status === "completed" ? 4 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1);
// 进入项目默认落到「项目当前进行到的阶段」(视频在生成/已生成 → 直接到第4阶段),而不是恒显第1阶段(ZWQ#16)。
// 仍尊重地址栏 #stage-N(分享某阶段链接 / 浏览器前进后退)。
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : projectStage);
const [navigated, setNavigated] = useState(Boolean(initHash));
// 火山人像审核状态(真人资产绿/红盾):本地覆盖 map(轮询刷新),回退 asset.review_status
const [reviews, setReviews] = useState<Record<string, string>>({});
@@ -3019,6 +3042,17 @@ export function PipelinePage(props: {
const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`) || pendingHas(kind, tag);
return (
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
{/* 待生成 seed 卡也可删除(还没出参考图也有垃圾桶):两步确认,直接从脚本标签里移除(ZWQ#9) */}
{seedDelArmed === seedKey ? (
<span className="asset-card-del-confirm" data-stop>
<button type="button" className="del-yes" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(null); deleteSeedTag(kind, tag); }}></button>
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(null); }}></button>
</span>
) : (
<button type="button" className="asset-card-del" data-stop title="删除该角色/场景" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(seedKey); window.setTimeout(() => setSeedDelArmed((v) => (v === seedKey ? null : v)), 3000); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder thumb-2" role="button" tabIndex={0} style={{ cursor: "pointer" }} title="点击进详情(先生成立绘,再生成三视图)"
onClick={() => openSeedDetail(kind, tag, promptValue)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }}>
{busy
@@ -4036,8 +4070,16 @@ export function PipelinePage(props: {
{portraitUrl && <button className="ad-stat-btn" type="button" onClick={() => void downloadAssetImage(viewPortraitAsset, `${entity.name}-立绘`)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg></button>}
</div>
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
if (viewPortraitAsset) await onAdoptBaseAsset(grp.id, viewPortraitAsset);
if (triGroup && viewTriAsset) await onAdoptBaseAsset(triGroup.id, viewTriAsset);
// 应用当前角色 = 采用 + 自动送审:不再要用户应用完还得手动点灰盾送审(ZWQ#7)。
// 立绘(person)/三视图(tri_view)都属送审类;送审异步(processing→绿盾),失败由 submitAssetReview 自行提示。
if (viewPortraitAsset) {
await onAdoptBaseAsset(grp.id, viewPortraitAsset);
if (assetReview(viewPortraitAsset) !== "active") void submitAssetReview(viewPortraitAsset);
}
if (triGroup && viewTriAsset) {
await onAdoptBaseAsset(triGroup.id, viewTriAsset);
if (assetReview(viewTriAsset) !== "active") void submitAssetReview(viewTriAsset);
}
setAdDetail(null);
}}></button>
</div>