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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user