fix(ai): 平台套图改走商品主图参考 image_edit · 模特上身图锁商品+模特双图

- 平台套图(cover)以前纯文生图,不参考真实商品主图 → 出图与商品对不上;
  现以商品主图为参考图1走 image_edit,锁包装一致性,有模特则参考图2=模特
- 新增 build_platform_cover_prompt_refs;model_url 解析对 cover 模式同样生效
- 补回归测试:cover 传商品主图、model 传商品主图+模特图、无主图回落文生图

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 21:58:39 +08:00
co-authored by Claude Opus 4.8
parent 7c6d4477da
commit 0695c36f4c
12 changed files with 455 additions and 126 deletions
+3 -2
View File
@@ -732,8 +732,9 @@
/* ════════════════════════════════════════════════
行31/32/33 · 批次卡 + 气泡菜单
════════════════════════════════════════════════ */
/* 批次卡:复用 .gen-card,批次间拉开间距 */
.image-workbench .gen-batch-card + .gen-batch-card { margin-top: 18px; }
/* 批次卡:复用 .gen-card。每批各带一个导航头(.iw-pv-h),批次间靠「卡片→下一个头」拉开间距;
首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */
.image-workbench .gen-batch-card + .iw-pv-h { margin-top: 22px; }
/* 批次头(数量 + 状态) */
.image-workbench .gen-batch-h {
display: flex; align-items: center; gap: 10px;
+1 -1
View File
@@ -446,7 +446,7 @@ export const api = {
return request<Paginated<AITask>>("/api/ai/tasks/");
},
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) {
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string }) {
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
generateImageStatus(ids: string[]) {
@@ -36,6 +36,9 @@
align-items: stretch;
}
.pd-overview .ov-card { height: 100%; box-sizing: border-box; }
/* 编辑商品信息时左栏变高:右侧快速操作不跟随拉伸,而是锁定为进编辑前(查看态)的高度,
整体大小保持不变。高度值由组件用 ref 实测后内联到 .ov-actions(见 products.tsx)。 */
.pd-overview:has(.ov-main.editing) { align-items: start; }
.pd-overview .ov-card {
background: var(--surface);
border: 1px solid var(--border-faint);
+38 -47
View File
@@ -779,22 +779,6 @@ export function ImageWorkbenchPage({
const hasResults = batches.length > 0;
/* 按商品分组(行34):各商品各自一个导航头,避免多商品批次被并到同一个头里。
保持首次出现顺序;productId 缺失的旧批次归到当前商品组。 */
const batchGroups = (() => {
const order: string[] = [];
const map = new Map<string, { key: string; title: string; items: GenBatch[] }>();
for (const b of batches) {
const key = b.productId || product?.id || "—";
if (!map.has(key)) {
order.push(key);
map.set(key, { key, title: b.productTitle || product?.title || "未选择", items: [] });
}
map.get(key)!.items.push(b);
}
return order.map((k) => map.get(k)!);
})();
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */
function renderBatchGrid(batch: GenBatch) {
const generating = batch.status === "generating";
@@ -854,7 +838,15 @@ export function ImageWorkbenchPage({
);
}
/* ── 批次列表(行31/32/33):每个批次一行卡片,底部一组操作 + 底部「更多」气泡 ── */
/* 批次所属商品名:优先生成时记下的 productTitle;旧批次缺失则用 productId 或成图的 asset.product
(后端已解析归属商品)反查 products——绝不回退到「当前选中商品」(那正是会显示错名的根因)。 */
function batchProductTitle(batch: GenBatch): string {
if (batch.productTitle) return batch.productTitle;
const pid = batch.productId || batch.results.find((a) => a.product)?.product || "";
return products.find((p) => p.id === pid)?.title || "未选择";
}
/* ── 批次列表(行31/32/33):每批各一个商品导航头 + 一张卡片 + 底部操作/气泡 ── */
function renderBatchCards(list: GenBatch[]) {
return (
<>
@@ -862,7 +854,32 @@ export function ImageWorkbenchPage({
const generating = batch.status === "generating";
const failed = batch.status === "failed";
return (
<div className="gen-card gen-batch-card" key={batch.id}>
<Fragment key={batch.id}>
{/* 行34:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
<div className="iw-pv-h">
<Quote className="quote-icon" />
<div className="pv-meta">
<b>1 </b>
{mode === "model" ? ` · ${batch.ratio}` : ""}
</div>
<div className="pv-line">
<span className="k"></span>
<span className="v">{batchProductTitle(batch)}</span>
</div>
{mode === "cover" && (
<div className="pv-line">
<span className="k"></span>
<span className="v">
{pickedIds.length
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
.map((p) => p.name)
.join("、")
: "未选择"}
</span>
</div>
)}
</div>
<div className="gen-card gen-batch-card">
<div className="gen-batch-h">
<span className="b-pic">{batch.count}×</span>
<div className="b-meta">
@@ -902,6 +919,7 @@ export function ImageWorkbenchPage({
</div>
</div>
</div>
</Fragment>
);
})}
</>
@@ -1343,35 +1361,8 @@ export function ImageWorkbenchPage({
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{/* 行34:按商品分组——每个商品一个独立导航头 + 自己的批次列表 */}
{batchGroups.map((group) => (
<Fragment key={group.key}>
<div className="iw-pv-h">
<Quote className="quote-icon" />
<div className="pv-meta">
<b>{group.items.length} </b>
{mode === "model" ? ` · ${ratio}` : ""}
</div>
<div className="pv-line">
<span className="k"></span>
<span className="v">{group.title}</span>
</div>
{mode === "cover" && (
<div className="pv-line">
<span className="k"></span>
<span className="v">
{pickedIds.length
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
.map((p) => p.name)
.join("、")
: "未选择"}
</span>
</div>
)}
</div>
{renderBatchCards(group.items)}
</Fragment>
))}
{/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */}
{renderBatchCards(batches)}
</>
)}
</div>
+3 -37
View File
@@ -1330,42 +1330,8 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
.modal-x { margin-left: auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; }
.modal-x:hover { background: var(--bg-soft); }
.msg-workbench { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 18px; min-height: 640px; }
.msg-panel { background: var(--card); border: 1px solid var(--border); min-width: 0; }
.msg-panel-h { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--border); }
.msg-panel-h .ti { font-weight: 600; }
.msg-filters { display: flex; gap: 6px; padding: 12px; flex-wrap: wrap; }
.msg-filter { border: 1px solid var(--border); background: var(--card); padding: 5px 9px; font-size: 12px; color: var(--ink-2); }
.msg-filter.active { background: var(--orange-tint); border-color: var(--orange-soft); color: var(--orange); font-weight: 600; }
.msg-filter .ct { margin-left: 6px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
.msg-search { display: flex; align-items: center; gap: 8px; margin: 0 12px 12px; border: 1px solid var(--border); background: var(--bg-soft); padding: 8px 10px; color: var(--ink-3); }
.msg-search input { border: 0; background: transparent; flex: 1; }
.msg-list { border-top: 1px solid var(--border); }
.msg-item { width: 100%; display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border); text-align: left; background: var(--card); }
.msg-item:hover, .msg-item.active { background: var(--orange-tint); }
.msg-type-ic { width: 28px; height: 28px; border: 1px solid var(--border); background: var(--bg-soft); display: grid; place-items: center; color: var(--orange); }
.msg-item-row, .msg-item-foot { display: flex; align-items: center; gap: 8px; }
.msg-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--orange); }
.msg-item-title { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-time, .msg-priority { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.msg-brief { display: block; color: var(--ink-2); font-size: 12px; line-height: 1.5; margin: 4px 0 7px; }
.msg-detail { display: flex; flex-direction: column; }
.msg-detail-body { padding: 20px; flex: 1; }
.msg-detail-top { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
.msg-detail-title h2 { font-size: 18px; line-height: 1.35; }
.msg-detail-title .meta { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; }
.msg-body-text { color: var(--ink-2); line-height: 1.7; margin-bottom: 16px; }
.msg-props { display: grid; grid-template-columns: 90px 1fr; gap: 0; border: 1px solid var(--border); margin-bottom: 16px; }
.msg-props .k, .msg-props .v { padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
.msg-props .k { background: var(--bg-soft); color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
.msg-timeline { border: 1px solid var(--border); }
.msg-timeline-h { padding: 10px 12px; border-bottom: 1px solid var(--border); font-weight: 600; }
.msg-step { display: grid; grid-template-columns: 70px 1fr; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.msg-step:last-child { border-bottom: 0; }
.msg-step .t { color: var(--orange); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.msg-detail-f { display: flex; gap: 8px; align-items: center; padding: 14px 16px; border-top: 1px solid var(--border); background: var(--bg-soft); }
.msg-foot-note { display: flex; justify-content: space-between; margin-top: 12px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.msg-foot-note button { color: var(--orange); }
/* 消息中心样式统一由 messages-page.css(对齐 design 稿)提供;此处旧版 V1 .msg-* 规则
用的是 --ink/--orange/--bg-soft 旧 token,会漏样式(灰底标签、搜索框描边、记录分隔线),已移除。 */
.factory-hero { display: grid; gap: 18px; margin-bottom: 26px; }
.factory-card { background: var(--card); border: 1px solid var(--border); position: relative; padding: 24px; }
@@ -1429,7 +1395,7 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
.switch input:checked + .slider::before { transform: translateX(18px); }
@media (max-width: 1180px) {
.wizard, .create-product-layout, .product-detail-layout, .top-grid, .team-top, .msg-workbench, .factory-body, .tool-layout, .demo-layout, .settings-layout, .overview-grid {
.wizard, .create-product-layout, .product-detail-layout, .top-grid, .team-top, .factory-body, .tool-layout, .demo-layout, .settings-layout, .overview-grid {
grid-template-columns: 1fr;
}
.wiz-preview, .steps, .create-upload-zone, .settings-side { position: static; }