fix(core): 资产详情大图随数据显示,缩略图已出大图不再卡转圈

- 三视图/立绘大图改为跟数据(url)走,出图完成即显示,不再被在途轮询的
  busy 标志挡住——根治「小缩略图已出、大图一直转圈」
- 一并带上工作区其余改动(script_agent / services / tests / ai-tools / actor-library / App)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-18 10:57:02 +08:00
co-authored by Claude Opus 4.8
parent fe38d3a512
commit 326c34ecd1
7 changed files with 200 additions and 47 deletions
+8 -6
View File
@@ -66,7 +66,7 @@ const crumbLabels: Partial<Record<Page, string>> = {
/* 图片生成工作台·跨刷新持久化:把在跑的任务 id + 已出结果按 mode 存本地,
刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */
const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`;
type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number };
type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string };
function loadImgwb(mode?: string): ImgwbSaved | null {
try {
const raw = localStorage.getItem(imgwbKey(mode));
@@ -410,7 +410,9 @@ export function App() {
const ids = tasks.map((t) => t.id);
if (ids.length === 0) throw new Error("未能提交生成任务");
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count });
// 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名)
const batchProduct = products.find((p) => p.id === payload.product_id);
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count, productId: payload.product_id, productTitle: batchProduct?.title });
return pollImageTasks(payload.mode, ids);
}, "图片已生成");
}
@@ -686,11 +688,11 @@ export function App() {
case "assetFactory":
return <AssetFactoryPage navigate={navigate} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
@@ -750,7 +752,7 @@ export function App() {
avatarChar={avatarChar}
logout={logout} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")}
onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")}
onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加")}
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")}
onRerunShot={(segmentId, instruction) => action(() => api.rerunScriptSegment(pipelineProject.id, { segment_id: segmentId, instruction }), "分镜已重跑")}
onSaveProjectMeta={(meta) =>
+24 -7
View File
@@ -18,7 +18,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
initialStudio?: boolean; // 打开即进「添加人物工作台」
assets: Asset[];
onClose: () => void;
onPick?: (assetId: string) => void | Promise<unknown>;
onPick?: (assetId: string, assetName?: string) => void | Promise<unknown>;
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
onUpload: (file: File) => Promise<Asset | null>;
// 据选中立绘生成配套三视图(后端吃任意 person 资产 id,不依赖该资产已在某 base group)
@@ -71,6 +71,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
const people = useMemo(() => source.filter((a) => a.category === "person" && previewOf(a)), [source]);
const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a)));
// 演员库分页(客户端):一页 10 个(5 列 × 2 行)。切 tab / 列表变化时回第 1 页,避免停在越界页。
const PAGE_SIZE = 10;
const [page, setPage] = useState(1);
const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
useEffect(() => { setPage(1); }, [tab, open]);
useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]);
const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
// 选某张候选立绘 → 进右侧栏(预填名字,清三视图标志)
function pickCandidate(a: Asset) {
setPicked(a);
@@ -126,8 +134,8 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
<div className="actorlib-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="actorlib" role="dialog" aria-modal="true" aria-label="演员库">
<div className="actorlib-h">
<h2>{mode === "replace" ? "演员库 · 选择演员替换" : "演员库"}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点演员卡即替换当前立绘" : "平台预设演员 / 我的演员"}</span>
<h2>{mode === "replace" ? "演员库 · 选择演员" : "演员库"}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点演员卡即选用应用" : "平台预设演员 / 我的演员"}</span>
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
@@ -230,15 +238,16 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
</button>
</div>
{list.length ? (
<>
<div className="actorlib-grid">
{list.map((a) => {
{pageList.map((a) => {
const url = previewOf(a);
return (
<div className="actor-card" key={a.id}>
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
role="button" tabIndex={0} title={mode === "replace" ? "选用此演员替换" : "查看大图"}
onClick={() => { if (mode === "replace") { void onPick?.(a.id); } else if (url) { setPreview({ src: url, name: a.name }); } }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id); } else if (url) { setPreview({ src: url, name: a.name }); } } }}>
role="button" tabIndex={0} title={mode === "replace" ? "选用此演员" : "查看大图"}
onClick={() => { if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } } }}>
{!url && <span className="ph-frame">{a.name}</span>}
{mode === "replace" && <span className="actor-pick"></span>}
</div>
@@ -247,6 +256,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
);
})}
</div>
{pageCount > 1 && (
<div className="actorlib-pager" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, marginTop: 16 }}>
<button className="btn btn-ghost btn-sm" type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}></button>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{page} / {pageCount}</span>
<button className="btn btn-ghost btn-sm" type="button" disabled={page >= pageCount} onClick={() => setPage((p) => Math.min(pageCount, p + 1))}></button>
</div>
)}
</>
) : (
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
<span className="ph-frame">// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"}</span>
+10 -3
View File
@@ -497,7 +497,8 @@ export function ImageWorkbenchPage({
navigate,
onGenerate,
onResume,
initialProductId
initialProductId,
onProductChange
}: {
mode: WorkMode;
products: Product[];
@@ -508,9 +509,13 @@ export function ImageWorkbenchPage({
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
onProductChange?: (productId: string) => void;
}) {
const meta = MODE_META[mode];
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
const product = products.find((item) => item.id === productId) || products[0];
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
const [ratio, setRatio] = useState(meta.ratio);
@@ -742,7 +747,7 @@ export function ImageWorkbenchPage({
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 };
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) {
@@ -752,7 +757,9 @@ export function ImageWorkbenchPage({
if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev;
return [
...prev,
{ id: batchId, prompt: meta.promptTemplate(product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: product?.id, productTitle: product?.title }
// 商品归属用生成时落盘的 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)
+18 -13
View File
@@ -424,7 +424,7 @@ export function PipelinePage(props: {
scriptModelName: string;
textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
onAddShot: (afterSegmentId: string) => Promise<unknown>;
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
onDeleteShot: (segmentId: string) => Promise<unknown>;
// 行35 · 单条分镜重跑(后端只重写该 segment);行28/34 · 把设定/标签合并进 project.metadata 持久化
onRerunShot?: (segmentId: string, instruction?: string) => Promise<unknown>;
@@ -650,7 +650,7 @@ export function PipelinePage(props: {
}
// 流程步骤4 · 演员库覆盖层:replace 带要替换的目标(已生成卡=groupId;seed 占位卡还没组=kind+label);
// browse 带 studio 直接进「添加人物工作台」
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string } | null>(null);
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string; addNew?: boolean } | null>(null);
function openActorReplace(entity: AssetEntity) {
setActorLib({ mode: "replace", groupId: entity.group.id });
}
@@ -658,9 +658,11 @@ export function PipelinePage(props: {
function openActorReplaceSeed(kind: "person" | "scene", tag: string) {
setActorLib({ mode: "replace", seedKind: kind, seedLabel: tag });
}
async function pickActor(assetId: string) {
async function pickActor(assetId: string, assetName?: string) {
if (actorLib?.mode === "replace") {
if (actorLib.groupId) await onAttachBaseAsset({ group_id: actorLib.groupId }, assetId);
// 新增人物(无既有 group / 无脚本标签):用所选演员名当 label 建一个新人物组挂上
else if (actorLib.addNew && actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: (assetName || "").trim() }, assetId);
else if (actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: actorLib.seedLabel }, assetId);
}
setActorLib(null);
@@ -2153,7 +2155,7 @@ export function PipelinePage(props: {
{/* 行34 · 该卡之后挂着的本地草稿分镜(点「添加分镜」插入,可编辑,有内容失焦才落库) */}
{draftShots.filter((d) => d.afterId === shot.id).map((draft) => (
<DraftShotCard key={draft.id} draft={draft}
onCommit={(d) => { setDraftShots((list) => list.filter((x) => x.id !== d.id)); if ((d.narration || d.visual).trim()) void onAddShot(d.afterId || shot.id); }}
onCommit={(d) => { setDraftShots((list) => list.filter((x) => x.id !== d.id)); if ((d.narration || d.visual).trim()) void onAddShot(d.afterId || shot.id, { narration: d.narration, visual_prompt: d.visual }); }}
onCancel={(id) => setDraftShots((list) => list.filter((x) => x.id !== id))} />
))}
<div className="shot-insert-gap">
@@ -2465,7 +2467,7 @@ export function PipelinePage(props: {
<h3>{KIND_LABEL[kind]} · {entities.length} </h3>
<span className="spacer"></span>
{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse", studio: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "replace", seedKind: "person", addNew: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
</div>
<div className="asset-grid-2">
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */}
@@ -3279,12 +3281,13 @@ export function PipelinePage(props: {
{/* 左:大立绘 + 版本缩略 */}
<div className="asset-detail-lead">
<div className="ad-lead-wrap">
<div className={`placeholder ad-lead-img${portraitUrl && !busyPortrait ? " has-mock-media" : ""}`} style={portraitUrl && !busyPortrait ? mediaStyle(portraitUrl) : undefined}>
{busyPortrait
{/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */}
<div className={`placeholder ad-lead-img${portraitUrl ? " has-mock-media" : ""}`} style={portraitUrl ? mediaStyle(portraitUrl) : undefined}>
{!portraitUrl && (busyPortrait
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame"></span></div>
: !portraitUrl && <span className="ph-frame"></span>}
: <span className="ph-frame"></span>)}
</div>
{portraitUrl && !busyPortrait && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
{portraitUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
</div>
{portraitVersions.length > 0 && (
<div className="ad-thumbs">
@@ -3311,12 +3314,14 @@ export function PipelinePage(props: {
</div>
<div className="asset-detail-tri-row">
<div className="ad-tri-wrap">
<div className={`placeholder${triUrl && !busyTri ? " has-mock-media" : ""}`} style={triUrl && !busyTri ? mediaStyle(triUrl) : undefined}>
{busyTri
{/* 大图随数据(triUrl)走,不被在途 poll 的 busyTri 挡住:出图完成(缩略图已有 url)即显示,
避免「缩略图已出、大图还在转圈」——busyTri 只在「还没任何结果」时显示生成中占位 */}
<div className={`placeholder${triUrl ? " has-mock-media" : ""}`} style={triUrl ? mediaStyle(triUrl) : undefined}>
{!triUrl && (busyTri
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame"></span></div>
: !triUrl && <span className="ph-frame"> / / · </span>}
: <span className="ph-frame"> / / · </span>)}
</div>
{triUrl && !busyTri && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
{triUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
</div>
</div>
{!triUrl && !busyTri && (