feat(core): 基础资产 Agent 化 — 商品三视图(image_edit 参考主图)+ 人物立绘/三视图异步生成 + 演员库/详情
- 商品三视图:有真实商品主图时走 image_edit,以主图为参考锁包装一致(品牌字/配色/外形/Logo) - 人物:据「某一版立绘」异步生成配套三视图(image_edit,worker 内跑),立绘/三视图各存版本可切换 - 基础资产出图改异步(run_base_asset_task / generate_triview_task),Web 层不被慢出图占住 - 前端:演员库浏览/新增、人物详情(立绘+三视图+版本切换/下载/查看大图)、按 busyKey 的单卡并发 loading(genBusy,替代全局 genBusyKey,各按钮互不阻塞) - dev:本地连云端 MySQL 复用连接(CONN_MAX_AGE/health check/connect_timeout),仅 development 生效 - 含 projects 测试补充 tsc + py_compile + 26 后端测试通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -441,6 +441,45 @@ export function App() {
|
||||
return pollImageTasks(mode, ids).catch(() => null);
|
||||
}
|
||||
|
||||
// 轮询一批 AITask(基础资产/三视图异步出图)直到终态;返回成功任务产出的 assets 与最后错误。
|
||||
// 轮询期间 Web 层是空闲的(只发轻量 status 请求),整站不卡;出图后调用方刷新项目即见新资产。
|
||||
async function pollAiTasks(ids: string[]): Promise<{ assets: Asset[]; error: string }> {
|
||||
const pending = new Set(ids);
|
||||
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const assets: Asset[] = [];
|
||||
let error = "";
|
||||
const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没好就停轮询(图仍会在后台出完,刷新可见)
|
||||
while (pending.size > 0 && Date.now() < deadline) {
|
||||
await sleep(2500);
|
||||
const res = await api.generateImageStatus([...pending]).catch(() => null);
|
||||
if (!res) continue;
|
||||
for (const t of res.tasks) {
|
||||
if (!TERMINAL.has(t.status)) continue;
|
||||
pending.delete(t.id);
|
||||
if (t.status === "succeeded") assets.push(...(t.assets || []));
|
||||
else if (t.error_message) error = t.error_message;
|
||||
}
|
||||
}
|
||||
return { assets, error };
|
||||
}
|
||||
|
||||
// 基础资产/三视图异步出图统一入口:提交(秒回任务)→ 轮询出图 → 刷新项目 → 返回新资产 id 供链式(立绘→三视图)。
|
||||
async function submitAndPollAsset(submit: () => Promise<{ task: { id: string; status: string } } | null>, okText: string): Promise<string | null> {
|
||||
const submitted = await submit().catch((e) => { setNotice({ type: "error", text: e instanceof Error ? e.message : "提交失败" }); return null; });
|
||||
const taskId = submitted?.task?.id;
|
||||
if (!taskId) return null; // 提交失败(余额不足/无 worker 等),错误已由 submit 抛出处理
|
||||
const { assets, error } = await pollAiTasks([taskId]);
|
||||
await refreshProjectDetail();
|
||||
void loadData();
|
||||
if (assets.length === 0) {
|
||||
setNotice({ type: "error", text: error || "生成超时,稍后可在资产里查看" });
|
||||
return null;
|
||||
}
|
||||
setNotice({ type: "success", text: okText });
|
||||
return assets[0].id;
|
||||
}
|
||||
|
||||
async function onAuthed(payload: { token: string; user: User; team: Team; remember?: boolean }) {
|
||||
setToken(payload.token, payload.remember ?? true);
|
||||
setUser(payload.user);
|
||||
@@ -564,6 +603,7 @@ export function App() {
|
||||
<ProjectWizardPage
|
||||
products={products}
|
||||
projects={projects}
|
||||
assets={assets}
|
||||
preselectProductId={activeProductId}
|
||||
onBack={() => navigate("projects")}
|
||||
onCreate={async (payload) => {
|
||||
@@ -694,7 +734,11 @@ export function App() {
|
||||
}
|
||||
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
|
||||
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
|
||||
onGenerateBaseAsset={(kind, prompt, label) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成")}
|
||||
onGenerateBaseAsset={async (kind, prompt, label) => {
|
||||
// 异步:提交→轮询出图→刷新;返回新资产 id 供「立绘→三视图」链式生成
|
||||
const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成");
|
||||
return assetId ? { adopted_asset: assetId } : null;
|
||||
}}
|
||||
onGenerateStoryboard={(prompt) =>
|
||||
action(async () => {
|
||||
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待
|
||||
@@ -711,7 +755,11 @@ export function App() {
|
||||
}
|
||||
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
||||
onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")}
|
||||
onGenerateTriview={(portraitAssetId) => action(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成")}
|
||||
onGenerateTriview={async (portraitAssetId) => {
|
||||
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
|
||||
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
|
||||
return assetId ? { id: assetId } : null;
|
||||
}}
|
||||
onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
|
||||
onUploadActor={(file) => {
|
||||
const fd = new FormData();
|
||||
|
||||
@@ -298,8 +298,9 @@ export const api = {
|
||||
generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) {
|
||||
return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢出图在 worker 跑,Web 不被占住)
|
||||
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string; label?: string }) {
|
||||
return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
||||
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
@@ -309,8 +310,9 @@ export const api = {
|
||||
return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset)
|
||||
// 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢 image_edit 在 worker 跑)
|
||||
generateTriview(projectId: string, payload: { portrait_asset_id: string }) {
|
||||
return request<{ id: string }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章
|
||||
pollReviews(projectId: string) {
|
||||
|
||||
@@ -521,26 +521,29 @@ export function PipelinePage(props: {
|
||||
});
|
||||
}
|
||||
|
||||
// 行38/流程步骤4 · 单卡生成 loading:action() 全局串行,这里只记「当前哪张卡在生成」做局部转圈
|
||||
const [genBusyKey, setGenBusyKey] = useState<string | null>(null);
|
||||
// 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物)
|
||||
const [genBusy, setGenBusy] = useState<Set<string>>(new Set());
|
||||
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; });
|
||||
type GenResult = { id?: string; adopted_asset?: string | null } | null;
|
||||
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise<GenResult> {
|
||||
if (genBusyKey) return null; // 全局一次只跑一张(后端同步出图,串行更省心)
|
||||
setGenBusyKey(busyKey);
|
||||
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
|
||||
addBusy(busyKey);
|
||||
try {
|
||||
return (await onGenerateBaseAsset(kind, prompt, label)) as GenResult;
|
||||
} finally {
|
||||
setGenBusyKey(null);
|
||||
delBusy(busyKey);
|
||||
}
|
||||
}
|
||||
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
|
||||
async function genTriview(portraitAssetId: string, busyKey: string): Promise<{ id: string } | null> {
|
||||
if (genBusyKey) return null;
|
||||
setGenBusyKey(busyKey);
|
||||
if (genBusy.has(busyKey)) return null;
|
||||
addBusy(busyKey);
|
||||
try {
|
||||
return await onGenerateTriview(portraitAssetId);
|
||||
} finally {
|
||||
setGenBusyKey(null);
|
||||
delBusy(busyKey);
|
||||
}
|
||||
}
|
||||
// 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘(返回组的 adopted_asset)→ 据它生成新三视图
|
||||
@@ -595,9 +598,9 @@ export function PipelinePage(props: {
|
||||
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次)
|
||||
const triAutoRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!adDetail || adDetail.kind !== "person" || genBusyKey) return;
|
||||
if (!adDetail || adDetail.kind !== "person") return;
|
||||
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
|
||||
if (!ent) return;
|
||||
if (!ent || isBusy(`addet-tri:${ent.key}`)) return;
|
||||
const portraitAsset = adPortraitId || ent.group.adopted_asset;
|
||||
if (!portraitAsset || triGroupForAsset(portraitAsset)) return;
|
||||
if (triAutoRef.current.has(portraitAsset)) return;
|
||||
@@ -1818,7 +1821,7 @@ export function PipelinePage(props: {
|
||||
// 流程步骤4 · 进入基础资产自动生成脚本里提取出、但还没生成的人物/场景占位卡(每项目仅一次)
|
||||
useEffect(() => {
|
||||
if (activeDot !== 2 && viewStage !== 2) return;
|
||||
if (autoGenRef.current || genBusyKey) return;
|
||||
if (autoGenRef.current) return;
|
||||
const flagKey = `airshelf:autogen:${project.id}`;
|
||||
try { if (localStorage.getItem(flagKey)) { autoGenRef.current = true; return; } } catch { /* ignore */ }
|
||||
const pending: Array<{ kind: "person" | "scene"; tag: string; prompt: string }> = [];
|
||||
@@ -2211,7 +2214,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 = genBusyKey === "tri:product";
|
||||
const triGenerating = isBusy("tri:product");
|
||||
const adoptedTriAsset = productGroup?.adopted_asset || "";
|
||||
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
||||
const hasTriView = productVersions.length > 0;
|
||||
@@ -2270,7 +2273,7 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<div className="prod-action" id="asset-prod-action">
|
||||
{/* 行39 · 点击展开右侧三视图预览面板并生成一版(对齐原版 prod-preview 交互) */}
|
||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={Boolean(genBusyKey)} onClick={runProductTri}>
|
||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>
|
||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
||||
{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}
|
||||
</button>
|
||||
@@ -2292,7 +2295,7 @@ export function PipelinePage(props: {
|
||||
})()}
|
||||
{previewTriAsset && (
|
||||
<div className="prod-preview-foot" id="prod-preview-foot">
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={Boolean(genBusyKey)} onClick={runProductTri}>↻ 重跑</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={triGenerating} onClick={runProductTri}>↻ 重跑</button>
|
||||
<button className="btn btn-sm prod-preview-adopt" type="button" disabled={previewTriAsset === adoptedTriAsset || loading} title={previewTriAsset === adoptedTriAsset ? "此版本已采用" : "将此版本设为商品采用版本"} onClick={() => { if (productGroup && previewTriAsset !== adoptedTriAsset) void onAdoptBaseAsset(productGroup.id, previewTriAsset); }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12l5 5L20 6" /></svg>
|
||||
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
|
||||
@@ -2344,14 +2347,14 @@ 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={Boolean(genBusyKey)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse" }); else void genBaseAsset("scene", genPrompt, undefined, customBusy); }}>{genBusyKey === customBusy ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse" }); else void genBaseAsset("scene", genPrompt, undefined, customBusy); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||
</div>
|
||||
<div className="asset-grid-2">
|
||||
{/* 脚本提取出但还没生成的人物/场景:seed 卡(可改提示词后生成,进入页面会自动补齐) */}
|
||||
{pendingTags.map((tag) => {
|
||||
const seedKey = `seed:${kind}:${tag}`;
|
||||
const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag);
|
||||
const busy = genBusyKey === seedKey;
|
||||
const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`);
|
||||
return (
|
||||
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
|
||||
<div className="placeholder thumb-2">
|
||||
@@ -2364,7 +2367,7 @@ export function PipelinePage(props: {
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={seedKey} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
|
||||
<div className="hstack" style={{ marginTop: 10 }}>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={Boolean(genBusyKey)} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2375,7 +2378,8 @@ export function PipelinePage(props: {
|
||||
const grp = entity.group;
|
||||
const mainUrl = groupMainUrl(grp);
|
||||
const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? "";
|
||||
const busy = genBusyKey === `ent:${kind}:${entity.key}`;
|
||||
const entBK = `ent:${kind}:${entity.key}`;
|
||||
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`);
|
||||
const rs = kind === "person" && grp.adopted_asset ? assetReview(grp.adopted_asset) : "";
|
||||
return (
|
||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
||||
@@ -2396,7 +2400,7 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={entity.key} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
|
||||
<div className="hstack" style={{ marginTop: 10 }}>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={Boolean(genBusyKey)} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const bk = `ent:${kind}:${entity.key}`; if (kind === "person") void genPersonWithTri(p, entity.name, bk); else void genBaseAsset(kind, p, entity.name, bk); }}>{busy ? "生成中…" : "重跑"}</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; if (kind === "person") void genPersonWithTri(p, entity.name, entBK); else void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}>替换</button>
|
||||
</div>
|
||||
@@ -3074,8 +3078,8 @@ export function PipelinePage(props: {
|
||||
const viewTriAsset = (adTriId && triVersions.includes(adTriId)) ? adTriId : (triGroup?.adopted_asset || triVersions.at(-1) || "");
|
||||
const triUrl = candUrl(triGroup, viewTriAsset);
|
||||
const pBK = `addet-portrait:${entity.key}`;
|
||||
const busyPortrait = genBusyKey === pBK;
|
||||
const busyTri = genBusyKey === `addet-tri:${entity.key}` || genBusyKey === `${pBK}:tri`;
|
||||
const busyPortrait = isBusy(pBK);
|
||||
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
|
||||
async function regenPortrait() {
|
||||
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
|
||||
// 重跑立绘 → 追加新候选并采用,人物链式据新立绘生成它的三视图;场景只重生立绘
|
||||
@@ -3148,7 +3152,7 @@ export function PipelinePage(props: {
|
||||
<div className="asset-detail-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
|
||||
<span>三视图据立绘自动生成,以保证正/侧/背多角度一致</span>
|
||||
<button className="ai-gen-btn" type="button" disabled={Boolean(genBusyKey) || !viewPortraitAsset} onClick={() => void regenTri()}>AI 生成三视图</button>
|
||||
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI 生成三视图</button>
|
||||
</div>
|
||||
)}
|
||||
{triVersions.length > 0 && (
|
||||
@@ -3169,7 +3173,7 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<textarea className="ad-detail-prompt" placeholder={isPerson ? "描述这个角色的立绘…" : "描述这个场景…"} value={adPrompt} onChange={(e) => setAdPrompt(e.target.value)} />
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
|
||||
<button className="btn btn-primary btn-sm" type="button" disabled={Boolean(genBusyKey)} onClick={() => void regenPortrait()}>
|
||||
<button className="btn btn-primary btn-sm" type="button" disabled={busyPortrait} onClick={() => void regenPortrait()}>
|
||||
{busyPortrait ? <span className="asset-spinner sm" aria-hidden="true" style={{ marginRight: 4 }}></span> : <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>}
|
||||
{busyPortrait ? "生成中…" : (isPerson ? "重跑立绘" : "重跑")}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import type { Product, Project } from "../types";
|
||||
import type { Asset, Product, Project } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { ConfirmModal, Drawer, EmptyPanel } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
@@ -28,9 +28,11 @@ type WizProductPayload = {
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
};
|
||||
|
||||
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
|
||||
export function ProjectWizardPage({ products, projects = [], assets = [], preselectProductId, onBack, onCreate, onCreateProduct }: {
|
||||
products: Product[];
|
||||
projects?: Project[];
|
||||
// 团队 assets:商品的 cover_asset/images[].asset 是资产 id,需在此反查真 preview_url
|
||||
assets?: Asset[];
|
||||
// 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。
|
||||
preselectProductId?: string;
|
||||
onBack: () => void;
|
||||
@@ -200,9 +202,18 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
}
|
||||
}
|
||||
|
||||
const productCover = (p: Product): CSSProperties | undefined => {
|
||||
const file = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || p.images?.[0]?.asset;
|
||||
return file ? ({ ["--mock-media-url"]: `url(${file})` } as CSSProperties) : undefined;
|
||||
// cover_asset / images[].asset 是资产 id,在团队 assets 里反查真 preview_url(对齐 products.tsx)
|
||||
const assetById = useMemo(() => {
|
||||
const map = new Map<string, Asset>();
|
||||
assets.forEach((a) => map.set(a.id, a));
|
||||
return map;
|
||||
}, [assets]);
|
||||
const productCoverUrl = (p: Product): string => {
|
||||
const firstImage = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order)[0];
|
||||
const id = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || firstImage?.asset;
|
||||
if (!id) return "";
|
||||
const asset = assetById.get(id);
|
||||
return asset?.files?.find((f) => f.is_primary)?.preview_url || asset?.files?.[0]?.preview_url || "";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -288,16 +299,23 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
{total === 0 ? (
|
||||
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
|
||||
) : (
|
||||
pageList.map((p) => (
|
||||
pageList.map((p) => {
|
||||
const coverUrl = productCoverUrl(p);
|
||||
return (
|
||||
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
|
||||
<div className={`placeholder product-thumb${productCover(p) ? " has-mock-media" : ""}`} style={productCover(p)}><span className="ph-frame">{p.title} · 1200×800</span></div>
|
||||
{coverUrl ? (
|
||||
<div className="product-thumb has-real-media"><img src={coverUrl} alt={p.title} loading="lazy" /></div>
|
||||
) : (
|
||||
<div className="placeholder product-thumb"><span className="ph-frame">{p.title} · 1200×800</span></div>
|
||||
)}
|
||||
<div className="product-body">
|
||||
<div className="product-name">{p.title}</div>
|
||||
<div className="product-cat">{p.category || "未分类"}</div>
|
||||
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user