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
+78 -76
View File
@@ -510,9 +510,10 @@ const MODE_META: Record<
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。",
desc: "选择平台模板,一键生成电商平台头图 / 主图。",
ratio: "4:5",
promptTemplate: (title) => `${title},电商平台套图,统一视觉,主图 + 详情排版`
// YYX#4:只出平台头图,去掉「详情排版」
promptTemplate: (title) => `${title},电商平台头图,统一视觉,主图特写`
}
};
@@ -582,6 +583,8 @@ type GenBatch = {
platformIds?: string[];
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
refs?: { name: string; url: string }[];
/** 该批次已提交的生图任务 id:落盘后切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
pendingIds?: string[];
};
export function ImageWorkbenchPage({
@@ -600,7 +603,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
@@ -732,14 +735,10 @@ export function ImageWorkbenchPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]);
// 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组)
// 模特 / 平台都单选(YYX#1:平台只能单选):再点同一张取消,点别张替换
function togglePick(id: string, name?: string) {
if (mode === "cover") {
setPickedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
return;
}
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
}
// 单图就地反馈(§4.18):显示 1.5s 后自动撤掉
function flashFeedback(key: string) {
@@ -770,8 +769,12 @@ export function ImageWorkbenchPage({
const batchKey = `airshelf:imgwb:batches:${mode}`;
function persistBatches(list: GenBatch[]) {
try {
// 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死)
const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status }));
// 已出图/失败的批次照常落盘;仍在跑(generating)但已拿到 pendingIds 的批次也落盘(保留 generating + pendingIds),
// 这样切走再回来每一批都能按各自的 pendingIds 续轮询,不再「数量少 / 只恢复一批」(PMC#5/#10)。
// 还没拿到 pendingIds 的占位批次(刚点、尚未提交成功)不落盘,避免恢复出一个永远空转的壳。
const slim = list
.filter((b) => b.status !== "generating" || (b.pendingIds && b.pendingIds.length))
.map((b) => ({ ...b, refs: undefined })); // refs 含本地 blob url,不落盘
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
} catch {
/* localStorage 不可用时静默降级 */
@@ -955,7 +958,9 @@ export function ImageWorkbenchPage({
referenceImageIds = ids.filter((x): x is string => !!x);
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds });
// onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
@@ -1002,7 +1007,7 @@ export function ImageWorkbenchPage({
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
for (const pid of pickedIds) {
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
void startBatch({ ...base, prompt: platName ? `${base.prompt}${platName}平台图版式` : base.prompt, platformIds: [pid] });
void startBatch({ ...base, prompt: platName ? `${base.prompt}${platName}平台图版式` : base.prompt, platformIds: [pid] });
}
return;
}
@@ -1116,55 +1121,39 @@ export function ImageWorkbenchPage({
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
useEffect(() => {
let cancelled = false;
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
if (mode === "image") return;
// 1) 多批次结果回显
// 模特/平台套图:从多批次残留恢复「全部」批次 —— 已出图/失败的直接回显;仍在跑(带 pendingIds)的
// 恢复成 generating 并各自按本批 pendingIds 续轮询。这样切走再回来,多批/多平台都在,不再「数量少 / 分类丢」(PMC#5/#10)。
try {
const raw = localStorage.getItem(batchKey);
if (raw) {
const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number };
if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) {
const restored = (saved.batches || []).filter((b) => b.results && b.results.length);
if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const })));
const all = saved.batches || [];
if (all.length) {
setBatches(all);
if (onResume) {
for (const b of all) {
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
onResume(mode, b.pendingIds).then((res) => {
if (cancelled) return;
setBatches((prev) => {
const next = prev.map((x) =>
x.id === b.id ? { ...x, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || x.results } : x
);
persistBatches(next);
return next;
});
});
}
}
}
}
}
} catch {
/* 残留解析失败则忽略 */
}
// 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报)
try {
const raw = localStorage.getItem(`airshelf:imgwb:${mode}`);
if (!raw) return;
const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string };
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return;
const pending = saved.pending || [];
if (pending.length && onResume) {
const batchId = `b-resume-${Date.now()}`;
setBatches((prev) => {
// 已由多批次残留恢复过结果就不重复加占位
if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev;
return [
...prev,
// 商品归属用生成时落盘的 saved.productId/Title,绝不用「当前选中商品」——切走再回来当前选中可能已变,
// 那正是导航头显示错名(如把耳机包批次显示成蓝牙耳机)的根因。
{ id: batchId, prompt: meta.promptTemplate(saved.productTitle || product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: saved.productId, productTitle: saved.productTitle }
];
});
onResume(mode, pending)
.then((res) => {
if (cancelled) return;
setBatches((prev) => {
const next = prev.map((b) =>
b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b
);
persistBatches(next);
return next;
});
});
}
} catch {
/* 残留解析失败则忽略 */
}
return () => {
cancelled = true;
};
@@ -1172,7 +1161,18 @@ export function ImageWorkbenchPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
const hasResults = batches.length > 0;
// YYX#3:工作台数据按商品隔离 —— 模特/平台模式右侧结果只展示当前选中商品的批次
// (旧批次无 productId 则全局可见,兜底);image 模式按对话维度,沿用全部 batches。
// YYX#12:右上搜索框(gridQuery)在平台套图下用来筛「工作台图片」(按提示词匹配),不再清空左侧平台网格。
const productBatches = useMemo(() => {
let list = mode === "image" ? batches : batches.filter((b) => !b.productId || b.productId === productId);
if (mode === "cover" && gridQuery.trim()) {
const q = gridQuery.trim().toLowerCase();
list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q));
}
return list;
}, [batches, productId, mode, gridQuery]);
const hasResults = productBatches.length > 0;
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除)
@@ -1187,14 +1187,19 @@ export function ImageWorkbenchPage({
className="gen-images"
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
>
{(batch.results.length
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id, inLib: !!asset.in_library }))
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "", inLib: false }))
).map(({ key, index, url, assetId, inLib }) => {
{/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败,
给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */}
{Array.from({ length: Math.max(batch.count, batch.results.length) }).map((_, index) => {
const asset = batch.results[index];
const url = asset?.files?.[0]?.preview_url;
const assetId = asset?.id || "";
const inLib = !!asset?.in_library;
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
const key = assetId || `ph-${index}`;
const cellKey = `${batch.id}:${assetId || key}`;
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
return (
<div className={`gen-image ${generating && !url ? "gen" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
{url ? (
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
) : (
@@ -1203,6 +1208,16 @@ export function ImageWorkbenchPage({
<span className="gen-loading" aria-label="生成中">
<span className="spinner" aria-hidden />
</span>
) : failed ? (
/* YYX#5:这张失败了 —— 占位提示 + 单张重跑 */
<span className="gen-failed">
<X size={18} />
<span className="gf-text"></span>
<button type="button" className="gf-retry" onClick={() => regenSingleImage(batch)}>
<RefreshCw size={12} />
</button>
</span>
) : (
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
)}
@@ -1679,21 +1694,7 @@ export function ImageWorkbenchPage({
{product?.title || "未选择 · 请在左侧商品空间选一个"}
</span>
</div>
{/* 生图模型选择(默认火山,可切 gpt-image-2;选择写入 localStorage) */}
<div className={`tb-menu-wrap chip-wrap${genModelOpen ? " open" : ""}`} data-filter="genmodel">
<button className="tb-chip" type="button" title="生图模型" onClick={() => setGenModelOpen((v) => !v)}>
<Sparkles size={12} />
<span className="lbl">{GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{GEN_MODEL_OPTIONS.map((o) => (
<div className={`mi${genModel === o.value ? " selected" : ""}`} key={o.value} role="button" tabIndex={0} onClick={() => { setGenModel(o.value); setGenModelOpen(false); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{o.label}
</div>
))}
</div>
</div>
{/* YYX#10:生图模型选择已移到下方提示词输入框内(胶囊靠左置底),此处不再放 */}
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
<LayoutGrid size={13} />
@@ -1702,7 +1703,7 @@ export function ImageWorkbenchPage({
<span className="spacer" />
<div className="tb-search-wrap">
{searchOpen && (
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索平台"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索图片提示词"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title={mode === "model" ? "搜索批次/模特" : "搜索"} onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
@@ -1806,7 +1807,8 @@ export function ImageWorkbenchPage({
<span className="title"></span>
</div>
<div className="platform-grid">
{PLATFORM_OPTIONS.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())).map((item) => (
{/* YYX#12:平台网格恒显全部,右上搜索只筛右侧结果图,不再把平台过滤没 */}
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
@@ -1932,8 +1934,8 @@ export function ImageWorkbenchPage({
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头 */}
{mode === "cover" ? renderCoverGrouped(batches) : renderBatchCards(batches)}
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头。按商品隔离 → productBatches */}
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
</>
)}
</div>
+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>