模特上身图提示词重构 + 图片创作页 UI 调整

后端(模特上身图提示词):
- build_model_tryon_prompt_refs 重写:穿戴/非穿戴分流(穿戴=真实穿身替换原衣,
  非穿戴=手持/佩戴/使用不动原衣)、每张按 index 变化动作/场景/镜头、负面词尾接、
  多图参考序号自适应(参考图1~N=商品,参考图N+1=模特)
- 新增 _product_reference_urls:商品参考图真实上传图优先、排除 AI 生成图、可多张(≤3),
  无真实图回落 cover
- worker run_standalone_image_task 模特分支改用多图取图 + 传 index/n_product

前端(图片创作/工作室):
- 生成数量改 1/2/4;图片比例新增「手动输入」(宽:高 两输入框)
- 临时隐藏「商品库」按钮
- 模特卡:去掉 // 真人模特,标题改图片底部白字遮罩层 + 单行省略
- 工作室壳负边距对齐 .content padding,修复上下被遮挡/裁切

其他:并入此前未提交的商品页改动、脚本 Agent/格式实测文档与 demo
This commit is contained in:
zyc
2026-06-27 09:31:44 +08:00
parent 4c8b2d1902
commit de4b20cc7b
24 changed files with 2350 additions and 127 deletions
+28 -7
View File
@@ -170,14 +170,20 @@
在 .content 内铺满可用高度(shell 由 App.tsx 渲染,这里只占正文)。
============================================================ */
.image-workbench {
/* 抵消 .content 的 48/28/72 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路) */
margin: -48px -28px -72px;
/* 抵消 .content 的 24/28/60 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路)
必须与 design-restraint.css .content 的 padding 严格一致:多减则顶部钻进 sticky
topbar(被遮)、底部溢出视口(被裁)。 */
margin: -24px -28px -60px;
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-base);
overflow: hidden;
}
/* <1100px 时 .content 改 28/24/48,负边距需同步,否则窄屏又会上下被遮/裁 */
@media (max-width: 1100px) {
.image-workbench { margin: -28px -24px -48px; }
}
/* ════════════════════════════════════════════════
通用:返回 pill(图片创作侧栏头 / 模特·平台侧栏头共用)
@@ -488,6 +494,18 @@
border-color: var(--heat-40);
font-weight: 600;
}
/* 手动输入比例:宽 : 高 两个数字框 */
.image-workbench .iw-ratio-manual {
display: flex; align-items: center; gap: 8px;
margin-top: 8px;
}
.image-workbench .iw-ratio-manual .input {
flex: 1; min-width: 0; height: 32px;
text-align: center;
}
.image-workbench .iw-ratio-manual .sep {
color: var(--black-alpha-48); font-size: 13px; flex-shrink: 0;
}
/* ── 模特选择 · 3:4 矩形卡多选(基线 .model-card)── */
.image-workbench .model-grid {
@@ -522,11 +540,14 @@
object-fit: cover; display: block;
background: var(--black-alpha-4);
}
.image-workbench .model-card .m-name { font-size: 13px; font-weight: 500; color: var(--accent-black); }
.image-workbench .model-card.selected .m-name { color: var(--heat); }
.image-workbench .model-card .m-tag {
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .02em;
/* 标题:遮罩层贴图片底部,白字单行省略,节省一行纵向空间 */
.image-workbench .model-card .m-name {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 14px 8px 6px;
font-size: 13px; font-weight: 500; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
background: linear-gradient(to top, rgba(0, 0, 0, .62), rgba(0, 0, 0, 0));
pointer-events: none; z-index: 1;
}
.image-workbench .model-card .m-check {
position: absolute; top: 14px; right: 14px;
+54 -10
View File
@@ -432,7 +432,7 @@ const MODE_META: Record<
const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
const COUNT_OPTIONS = ["1", "2", "4"];
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
const MODEL_COUNT_OPTIONS = ["4", "8", "12"];
const MODEL_COUNT_OPTIONS = ["1", "2", "4"];
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
@@ -528,6 +528,10 @@ export function ImageWorkbenchPage({
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);
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
const [ratioManual, setRatioManual] = useState(false);
const [ratioW, setRatioW] = useState("");
const [ratioH, setRatioH] = useState("");
const [style, setStyle] = useState("auto");
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
@@ -613,6 +617,9 @@ export function ImageWorkbenchPage({
if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置 prompt 与默认比例
setRatio(meta.ratio);
setRatioManual(false);
setRatioW("");
setRatioH("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]);
@@ -1323,11 +1330,11 @@ export function ImageWorkbenchPage({
{product?.title || "未选择 · 请在左侧商品空间选一个"}
</span>
</div>
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) */}
<button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
<LayoutGrid size={13} />
商品库
</button>
</button> */}
<span className="spacer" />
<div className="tb-search-wrap">
{searchOpen && (
@@ -1402,9 +1409,8 @@ export function ImageWorkbenchPage({
<span className="ph-frame">{item.name.slice(0, 4)}</span>
</div>
)}
<div className="m-name">{item.name}</div>
</div>
<div className="m-name">{item.name}</div>
<div className="m-tag">// 真人模特</div>
</button>
);
})
@@ -1423,9 +1429,8 @@ export function ImageWorkbenchPage({
</span>
<div className="placeholder m-thumb">
<span className="ph-frame">{item.name}</span>
<div className="m-name">{item.name}</div>
</div>
<div className="m-name">{item.name}</div>
<div className="m-tag">{item.tag}</div>
</button>
))}
</div>
@@ -1481,13 +1486,52 @@ export function ImageWorkbenchPage({
<button
type="button"
key={value}
className={`opt ${ratio === value ? "active" : ""}`}
onClick={() => setRatio(value)}
className={`opt ${!ratioManual && ratio === value ? "active" : ""}`}
onClick={() => { setRatioManual(false); setRatio(value); }}
>
{value}
</button>
))}
<button
type="button"
className={`opt ${ratioManual ? "active" : ""}`}
onClick={() => {
setRatioManual(true);
if (ratioW && ratioH) setRatio(`${ratioW}:${ratioH}`);
}}
>
</button>
</div>
{ratioManual && (
<div className="iw-ratio-manual">
<input
className="input"
type="number"
min={1}
placeholder="宽"
value={ratioW}
onChange={(event) => {
const w = event.target.value;
setRatioW(w);
if (w && ratioH) setRatio(`${w}:${ratioH}`);
}}
/>
<span className="sep">:</span>
<input
className="input"
type="number"
min={1}
placeholder="高"
value={ratioH}
onChange={(event) => {
const h = event.target.value;
setRatioH(h);
if (ratioW && h) setRatio(`${ratioW}:${h}`);
}}
/>
</div>
)}
</div>
)}
<div className="iw-sub">
+66 -85
View File
@@ -10,6 +10,8 @@ import { SkeletonGrid } from "../components/loading";
import { api } from "../api";
const PROD_PAGE_SIZE = 10;
// 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行)
const MAT_PAGE_SIZE = 12;
import type { Asset, Product, ProductMaterials, Project } from "../types";
import type { NavigateFn, Page } from "./route-config";
import "../product-create-page.css";
@@ -621,13 +623,8 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const [triAdoptedId, setTriAdoptedId] = useState<string>("");
const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0];
const triUrl = triViewing?.url || "";
// 素材 tab 筛选 / 排序 / 分页 / 视图
const [openFilter, setOpenFilter] = useState<"" | "type" | "status" | "sort">("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"" | "pass" | "fail" | "pending">("");
const [assetView, setAssetView] = useState<"grid" | "list">("grid");
const [assetSortDesc, setAssetSortDesc] = useState(true);
const [assetLimit, setAssetLimit] = useState(12);
// AI 素材 grid 当前页(与全站列表一致:整体翻页)
const [matPage, setMatPage] = useState(1);
const [videoSortDesc, setVideoSortDesc] = useState(true);
// 素材审核状态为只读(直接反映 review_status),不再有用户点击覆盖
// 采用三视图后,本地 prepend 的新素材卡(供「采用版本联动 + prepend 新卡」即时呈现,不等服务端回灌)
@@ -665,19 +662,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
if (!alive) return;
setProductAssets(assetsRes?.results ?? []);
setMaterials(matRes);
setMatPage(1); // 切商品 / 刷新素材后回到第 1 页
}).finally(() => { if (alive) setAssetsLoading(false); });
return () => { alive = false; };
}, [product?.id, assetReload]);
useEffect(() => {
if (!openFilter) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenFilter("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openFilter]);
async function onPickProductImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
@@ -757,29 +746,27 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
// 采用三视图后本地 prepend 的新卡:置顶且与服务端列表去重(服务端回灌同 id 后即不再重复)
const serverIds = new Set(serverImageAssets.map((a) => a.id));
const allImageAssets = [...adoptedAssets.filter((a) => !serverIds.has(a.id)), ...serverImageAssets];
// 联动采用状态前的「全部素材基线」(供 adoptTriView 找出该商品所有三视图)
const allAssetsBase = allImageAssets;
// 单个素材当前显示状态:本地覆盖(点击循环 / 采用联动)优先,否则回退真实 review_status 映射
const statusOf = (asset: Asset): PdAssetStatus => pdBaseStatus(asset);
// 类型筛选选项(当前素材里真实存在的 category)
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
// 全集计数(给状态筛选下拉做计数前缀,不随当前筛选变化)
const filteredAssets = allImageAssets
.filter((asset) => !typeFilter || asset.category === typeFilter)
.filter((asset) => !statusFilter || statusOf(asset) === statusFilter)
.slice()
.sort((a, b) => {
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return assetSortDesc ? cmp : -cmp;
});
const assetCount = filteredAssets.length;
const imageAssets = filteredAssets.slice(0, assetLimit);
// 结构化素材总数(角色 + 商品图 + 图片成品 + 项目包内素材),用于头部计数 / 空态判断
const matTotal = materials
? materials.roles.length + materials.product_images.length + materials.image_products.length + materials.project_packs.reduce((s, p) => s + p.items.length, 0)
: 0;
const hasActiveAssetFilter = Boolean(typeFilter || statusFilter);
const resetAssetFilters = () => { setTypeFilter(""); setStatusFilter(""); setAssetLimit(12); setOpenFilter(""); };
// 拍平 4 类素材为单序列(角色 → 商品图 → 图片成品 → 视频项目包),整体翻页(与全站列表统一 Pager 一致)
type MatItem =
| { kind: "role"; role: ProductMaterials["roles"][number] }
| { kind: "product_image"; asset: Asset }
| { kind: "image_product"; asset: Asset }
| { kind: "pack"; pack: ProductMaterials["project_packs"][number] };
const matItems: MatItem[] = materials
? [
...materials.roles.map((role) => ({ kind: "role", role } as MatItem)),
...materials.product_images.map((asset) => ({ kind: "product_image", asset } as MatItem)),
...materials.image_products.map((asset) => ({ kind: "image_product", asset } as MatItem)),
...materials.project_packs.map((pack) => ({ kind: "pack", pack } as MatItem)),
]
: [];
const matTotalPages = Math.max(1, Math.ceil(matItems.length / MAT_PAGE_SIZE));
const matCurPage = Math.min(matPage, matTotalPages);
const matPageItems = matItems.slice((matCurPage - 1) * MAT_PAGE_SIZE, matCurPage * MAT_PAGE_SIZE);
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序)
const videoProjects = [...projects].sort((a, b) => {
const cmp = (b.updated_at || "").localeCompare(a.updated_at || "");
@@ -1075,68 +1062,62 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}></button>
</div>
) : (
<>
<div className="asset-grid pd-mat-grid">
{/* 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套 */}
{materials!.roles.map((role) => {
const cover = pdAssetPreview(role.portrait);
return (
<div className="asset-card pd-role-card" key={`role-${role.id}`} role="button" tabIndex={0} title={role.name}
onClick={() => setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}>
<div className="thumb placeholder">
{cover ? <img src={cover} alt={role.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
{role.triview && <span className="pd-role-tri mono"></span>}
{matPageItems.map((item) => {
// 角色:逐个(形象图+三视图绑一起);左上标签、左下「N 个项目引用」;点开看这套
if (item.kind === "role") {
const role = item.role;
const cover = pdAssetPreview(role.portrait);
return (
<div className="asset-card pd-role-card" key={`role-${role.id}`} role="button" tabIndex={0} title={role.name}
onClick={() => setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: role.name, sub: role.ref_count > 0 ? `角色 · ${role.ref_count} 个项目引用` : "角色 · 暂未引用", assets: [role.portrait, role.triview].filter(Boolean) as Asset[] }); } }}>
<div className="thumb placeholder">
{cover ? <img src={cover} alt={role.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
{role.triview && <span className="pd-role-tri mono"></span>}
</div>
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}</span></div>
</div>
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}</span></div>
</div>
);
})}
{/* 商品图:平铺 */}
{materials!.product_images.map((a) => {
);
}
// 视频项目包:场景+分镜+视频素材按项目打包,点开看整组
if (item.kind === "pack") {
const pack = item.pack;
const first = pack.items[0];
const cover = pdAssetPreview(first);
const isVid = first?.asset_type === "video";
return (
<div className="asset-card pd-pack-card" key={`pack-${pack.project_id || pack.project_name}`} role="button" tabIndex={0} title={pack.project_name}
onClick={() => setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}>
<div className="thumb placeholder">
{isVid && cover ? <video src={cover} muted playsInline preload="metadata" /> : cover ? <img src={cover} alt={pack.project_name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
<span className="pd-group-count mono">{pack.items.length}</span>
</div>
<div className="meta"><span className="date">{pack.project_name}</span></div>
</div>
);
}
// 商品图 / 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签
const a = item.asset;
const u = pdAssetPreview(a);
const label = item.kind === "product_image" ? "商品图" : pdAssetTypeLabel(a);
return (
<div className="asset-card" key={a.id}>
<div className="thumb placeholder" role={u ? "button" : undefined} style={u ? { cursor: "zoom-in" } : undefined} onClick={u ? () => setPreview({ src: u, name: a.name }) : undefined}>
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame">{label}</span>}
<span className="type-pill">{label}</span>
</div>
<div className="meta"><span className="date">{(a.created_at || "").slice(0, 10)}</span></div>
</div>
);
})}
{/* 图片成品(模特上身图/平台套图/自由创作):平铺,各带类型标签 */}
{materials!.image_products.map((a) => {
const u = pdAssetPreview(a);
return (
<div className="asset-card" key={a.id}>
<div className="thumb placeholder" role={u ? "button" : undefined} style={u ? { cursor: "zoom-in" } : undefined} onClick={u ? () => setPreview({ src: u, name: a.name }) : undefined}>
{u ? <img src={u} alt={a.name} loading="lazy" /> : <span className="ph-frame">{pdAssetTypeLabel(a)}</span>}
<span className="type-pill">{pdAssetTypeLabel(a)}</span>
</div>
<div className="meta"><span className="date">{(a.created_at || "").slice(0, 10)}</span></div>
</div>
);
})}
{/* 视频项目包:场景+分镜+视频素材按项目打包,点开看整组 */}
{materials!.project_packs.map((pack) => {
const first = pack.items[0];
const cover = pdAssetPreview(first);
const isVid = first?.asset_type === "video";
return (
<div className="asset-card pd-pack-card" key={`pack-${pack.project_id || pack.project_name}`} role="button" tabIndex={0} title={pack.project_name}
onClick={() => setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items })}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDetail({ title: pack.project_name, sub: `视频项目 · ${pack.items.length} 个素材`, assets: pack.items }); } }}>
<div className="thumb placeholder">
{isVid && cover ? <video src={cover} muted playsInline preload="metadata" /> : cover ? <img src={cover} alt={pack.project_name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="type-pill"></span>
<span className="pd-group-count mono">{pack.items.length}</span>
</div>
<div className="meta"><span className="date">{pack.project_name}</span></div>
</div>
);
})}
</div>
<Pager page={matCurPage} total={matItems.length} pageSize={MAT_PAGE_SIZE} onChange={setMatPage} />
</>
)}
</div>