polish: 商品/场景三视图「替换」选图层——通用化 + 场景修复 + 过滤分页
- 商品三视图「替换」数据源修复:旧实现只读 productRecord.images(封面1张) → 改为 fetch 后端 ?product= 三路并集,只列商品图(product_image)。 - 页脚样式对齐人物/场景卡:hstack 左 ghost「替换」+ 右 primary「AI 生成」。 - 场景卡「替换」修复:旧实现误用人物专用演员库(列出角色数据)→ 场景改走 通用选图层,按 category=scene 取;人物仍走演员库。覆盖 seed/已生成/详情 3 入口。 - 场景过滤:故事板帧也被存成 category=scene(metadata 全空,只能按名字), 过滤掉 storyboard/分镜,只留真场景图。 - 商品/场景选图均加分页,20 个一页(上一页 / X·共N / 下一页)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -603,34 +603,74 @@ export function PipelinePage(props: {
|
|||||||
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||||
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||||
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
||||||
// 问题5(a):用「已有素材」直接当最终三视图——选商品已有图 / 上传本地图,attach 成商品组采用版(不走平台 AI 生成、不计费)。
|
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。
|
||||||
const [triPickOpen, setTriPickOpen] = useState(false);
|
// 人物不走这里——人物有专门的演员库(ActorLibrary,含模特库/三视图工作台),对人物是对的。
|
||||||
const [triPickBusy, setTriPickBusy] = useState(false);
|
// 旧 bug:场景卡「替换」误用了演员库 → 列的是角色数据。现在场景改走本选图层。
|
||||||
const triFileRef = useRef<HTMLInputElement | null>(null);
|
type AssetPick = {
|
||||||
async function attachExistingTri(assetId: string) {
|
title: string;
|
||||||
setTriPickBusy(true);
|
category: "product_image" | "scene";
|
||||||
|
product?: string; // 传则按商品过滤(商品三视图);不传则团队全量(场景)
|
||||||
|
target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string };
|
||||||
|
uploadCategory: string;
|
||||||
|
uploadName: string;
|
||||||
|
};
|
||||||
|
const [assetPick, setAssetPick] = useState<AssetPick | null>(null);
|
||||||
|
const [assetPickBusy, setAssetPickBusy] = useState(false);
|
||||||
|
const [assetPickList, setAssetPickList] = useState<Asset[]>([]);
|
||||||
|
const [assetPickLoading, setAssetPickLoading] = useState(false);
|
||||||
|
const [assetPickPage, setAssetPickPage] = useState(1);
|
||||||
|
const ASSET_PICK_PAGE_SIZE = 20; // 商品/场景选图:20 个一页
|
||||||
|
const pickFileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const pickPreview = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
|
||||||
|
useBodyScrollLock(Boolean(assetPick));
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assetPick) return;
|
||||||
|
setAssetPickLoading(true);
|
||||||
|
setAssetPickList([]);
|
||||||
|
setAssetPickPage(1);
|
||||||
|
// 后端 ?product= 取三路并集(独立生图 metadata.product_id + 项目内生成 origin_task→project→product + 上传图);
|
||||||
|
// 不传 product 则团队全量(场景常跨商品复用)。category 限定只列该类素材,排除噪音。多取一点供前端分页。
|
||||||
|
api.assetsPage({ ...(assetPick.product ? { product: assetPick.product } : {}), asset_type: "image", category: assetPick.category, pageSize: 200, ordering: "-created_at" })
|
||||||
|
.then((res) => setAssetPickList(res.results || []))
|
||||||
|
.catch(() => setAssetPickList([]))
|
||||||
|
.finally(() => setAssetPickLoading(false));
|
||||||
|
}, [assetPick]);
|
||||||
|
// 场景里混进了故事板帧(也被存成 category=scene,只能靠名字区分;metadata 全空)→ 过滤掉,只留真场景图。
|
||||||
|
const assetPickFiltered = useMemo(() => {
|
||||||
|
if (!assetPick) return [];
|
||||||
|
return assetPick.category === "scene"
|
||||||
|
? assetPickList.filter((a) => !/storyboard|story-board|分镜/i.test(a.name || ""))
|
||||||
|
: assetPickList;
|
||||||
|
}, [assetPick, assetPickList]);
|
||||||
|
const assetPickPageCount = Math.max(1, Math.ceil(assetPickFiltered.length / ASSET_PICK_PAGE_SIZE));
|
||||||
|
useEffect(() => { setAssetPickPage((p) => Math.min(p, assetPickPageCount)); }, [assetPickPageCount]);
|
||||||
|
const assetPickPageList = assetPickFiltered.slice((assetPickPage - 1) * ASSET_PICK_PAGE_SIZE, assetPickPage * ASSET_PICK_PAGE_SIZE);
|
||||||
|
async function attachPickedAsset(assetId: string) {
|
||||||
|
if (!assetPick) return;
|
||||||
|
setAssetPickBusy(true);
|
||||||
try {
|
try {
|
||||||
await onAttachBaseAsset({ kind: "product" }, assetId); // 后端 attach-base-asset 按 kind=product 命中/建商品组并采用
|
await onAttachBaseAsset(assetPick.target, assetId); // 后端 attach-base-asset 按 group_id / kind+label 命中或建组并采用
|
||||||
setTriPickOpen(false);
|
setAssetPick(null);
|
||||||
} finally {
|
} finally {
|
||||||
setTriPickBusy(false);
|
setAssetPickBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function uploadTriFromFile(file: File) {
|
async function uploadPickedFromFile(file: File) {
|
||||||
setTriPickBusy(true);
|
if (!assetPick) return;
|
||||||
|
setAssetPickBusy(true);
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
fd.append("asset_type", "image");
|
fd.append("asset_type", "image");
|
||||||
fd.append("category", "tri_view"); // 标三视图类(与生成的三视图同类,送审/展示口径一致)
|
fd.append("category", assetPick.uploadCategory);
|
||||||
fd.append("name", `${productName || "商品"}·三视图`);
|
fd.append("name", assetPick.uploadName);
|
||||||
const asset = await api.uploadAsset(fd); // 落 TOS + 建 team 资产,返回带 id
|
const asset = await api.uploadAsset(fd); // 落 TOS + 建 team 资产,返回带 id
|
||||||
await onAttachBaseAsset({ kind: "product" }, asset.id);
|
await onAttachBaseAsset(assetPick.target, asset.id);
|
||||||
setTriPickOpen(false);
|
setAssetPick(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试");
|
onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setTriPickBusy(false);
|
setAssetPickBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||||||
@@ -870,6 +910,13 @@ export function PipelinePage(props: {
|
|||||||
function openActorReplaceSeed(kind: "person" | "scene", tag: string) {
|
function openActorReplaceSeed(kind: "person" | "scene", tag: string) {
|
||||||
setActorLib({ mode: "replace", seedKind: kind, seedLabel: tag });
|
setActorLib({ mode: "replace", seedKind: kind, seedLabel: tag });
|
||||||
}
|
}
|
||||||
|
// 场景「替换」:走通用选图层(取团队场景图),而非人物专用的演员库(修复:场景替换误列角色数据)。
|
||||||
|
function openSceneReplaceSeed(tag: string) {
|
||||||
|
setAssetPick({ title: "选择场景素材", category: "scene", target: { kind: "scene", label: tag }, uploadCategory: "scene", uploadName: `${tag || "场景"}·场景图` });
|
||||||
|
}
|
||||||
|
function openSceneReplaceEntity(groupId: string, label?: string) {
|
||||||
|
setAssetPick({ title: "选择场景素材", category: "scene", target: { group_id: groupId }, uploadCategory: "scene", uploadName: `${label || "场景"}·场景图` });
|
||||||
|
}
|
||||||
async function pickActor(assetId: string, assetName?: string) {
|
async function pickActor(assetId: string, assetName?: string) {
|
||||||
if (actorLib?.mode === "replace") {
|
if (actorLib?.mode === "replace") {
|
||||||
if (actorLib.groupId) await onAttachBaseAsset({ group_id: actorLib.groupId }, assetId);
|
if (actorLib.groupId) await onAttachBaseAsset({ group_id: actorLib.groupId }, assetId);
|
||||||
@@ -2843,13 +2890,13 @@ export function PipelinePage(props: {
|
|||||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prod-action" id="asset-prod-action">
|
<div className="prod-action" id="asset-prod-action">
|
||||||
{/* 行39 · 点击展开右侧三视图预览面板并生成一版(对齐原版 prod-preview 交互) */}
|
{/* 与人物/场景卡页脚一致:hstack 左「使用已有素材」(ghost) + spacer + 右「AI 生成」(primary) */}
|
||||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>
|
<div className="hstack">
|
||||||
<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>
|
|
||||||
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
|
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triPickBusy} onClick={() => setTriPickOpen(true)}>使用已有素材</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || assetPickBusy} onClick={() => setAssetPick({ title: "使用已有素材作三视图", category: "product_image", product: project.product, target: { kind: "product" }, uploadCategory: "tri_view", uploadName: `${productName || "商品"}·三视图` })}>替换</button>
|
||||||
|
<span className="spacer"></span>
|
||||||
|
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
|
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
|
||||||
@@ -2969,7 +3016,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="hstack"><strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
|
<div className="hstack"><strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
|
||||||
<PromptBox key={seedKey} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
|
<PromptBox key={seedKey} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
|
||||||
<div className="hstack" style={{ marginTop: 10 }}>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => openActorReplaceSeed(kind, tag)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => kind === "scene" ? openSceneReplaceSeed(tag) : openActorReplaceSeed(kind, tag)}>替换</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); 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); void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3031,7 +3078,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="hstack" style={{ marginTop: 10 }}>
|
<div className="hstack" style={{ marginTop: 10 }}>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; void genBaseAsset(kind, p, entity.name, entBK); }}>{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; void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => kind === "scene" ? openSceneReplaceEntity(entity.group.id, entity.name) : openActorReplace(entity)}>替换</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3948,7 +3995,10 @@ export function PipelinePage(props: {
|
|||||||
{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 ? <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 ? "生成中…" : (portraitVersions.length === 0 ? (isPerson ? "生成立绘" : "生成场景图") : (isPerson ? "重跑立绘" : "重跑"))}
|
{busyPortrait ? "生成中…" : (portraitVersions.length === 0 ? (isPerson ? "生成立绘" : "生成场景图") : (isPerson ? "重跑立绘" : "重跑"))}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => isSeed ? openActorReplaceSeed(adDetail.kind, entity.name) : openActorReplace(entity)}>替换</button>
|
<button className="btn btn-ghost btn-sm" type="button" onClick={() => {
|
||||||
|
if (adDetail.kind === "scene") { isSeed ? openSceneReplaceSeed(entity.name) : openSceneReplaceEntity(entity.group.id, entity.name); }
|
||||||
|
else { isSeed ? openActorReplaceSeed(adDetail.kind, entity.name) : openActorReplace(entity); }
|
||||||
|
}}>替换</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3969,32 +4019,45 @@ export function PipelinePage(props: {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */}
|
{/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */}
|
||||||
{triPickOpen && (
|
{assetPick && (
|
||||||
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label="使用已有素材作三视图" onClick={(e) => { if (e.target === e.currentTarget && !triPickBusy) setTriPickOpen(false); }}>
|
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={assetPick.title} onClick={(e) => { if (e.target === e.currentTarget && !assetPickBusy) setAssetPick(null); }}>
|
||||||
<div className="asset-modal">
|
<div className="asset-modal">
|
||||||
<div className="asset-modal-h">
|
<div className="asset-modal-h">
|
||||||
<h2>使用已有素材作三视图</h2>
|
<h2>{assetPick.title}</h2>
|
||||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 选商品图或上传本地图,直接作最终三视图</span>
|
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 选已有图或上传本地图,点一下直接采用</span>
|
||||||
<button className="x" type="button" aria-label="关闭" disabled={triPickBusy} onClick={() => setTriPickOpen(false)}>
|
<button className="x" type="button" aria-label="关闭" disabled={assetPickBusy} onClick={() => setAssetPick(null)}>
|
||||||
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-modal-body">
|
<div className="asset-modal-body">
|
||||||
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriFromFile(f); e.target.value = ""; }} />
|
<input ref={pickFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadPickedFromFile(f); e.target.value = ""; }} />
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: "12px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: "12px" }}>
|
||||||
{/* 上传本地图 tile */}
|
{/* 上传本地图 tile */}
|
||||||
<div className="placeholder" role="button" tabIndex={0} aria-disabled={triPickBusy} title="上传本地图片作三视图" style={{ aspectRatio: "16/9", display: "grid", placeItems: "center", cursor: triPickBusy ? "default" : "pointer" }} onClick={() => { if (!triPickBusy) triFileRef.current?.click(); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); triFileRef.current?.click(); } }}>
|
<div className="placeholder" role="button" tabIndex={0} aria-disabled={assetPickBusy} title="上传本地图片" style={{ aspectRatio: "16/9", display: "grid", placeItems: "center", cursor: assetPickBusy ? "default" : "pointer" }} onClick={() => { if (!assetPickBusy) pickFileRef.current?.click(); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !assetPickBusy) { e.preventDefault(); pickFileRef.current?.click(); } }}>
|
||||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>{triPickBusy ? "处理中…" : "+ 上传本地图"}</span>
|
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>{assetPickBusy ? "处理中…" : "+ 上传本地图"}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 商品已有图:点击直接采用为三视图 */}
|
{/* 已有素材:当前页(点击直接采用) */}
|
||||||
{(productRecord?.images ?? []).map((img) => (
|
{assetPickPageList.map((a) => {
|
||||||
<div key={img.id} className={`placeholder${img.preview_url ? " has-mock-media is-zoomable" : ""}`} role="button" tabIndex={0} title="点击采用为三视图" aria-disabled={triPickBusy} style={{ aspectRatio: "16/9", cursor: triPickBusy ? "default" : "pointer", ...(img.preview_url ? mediaStyle(img.preview_url) : {}) }} onClick={() => { if (!triPickBusy) void attachExistingTri(img.asset); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); void attachExistingTri(img.asset); } }}>
|
const u = pickPreview(a);
|
||||||
{!img.preview_url && <span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-48)" }}>// 无预览</span>}
|
return (
|
||||||
|
<div key={a.id} className={`placeholder${u ? " has-mock-media is-zoomable" : ""}`} role="button" tabIndex={0} title={a.name || "点击采用"} aria-disabled={assetPickBusy} style={{ aspectRatio: "16/9", cursor: assetPickBusy ? "default" : "pointer", ...(u ? mediaStyle(u) : {}) }} onClick={() => { if (!assetPickBusy) void attachPickedAsset(a.id); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !assetPickBusy) { e.preventDefault(); void attachPickedAsset(a.id); } }}>
|
||||||
|
{!u && <span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-48)" }}>// 无预览</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{assetPickLoading
|
||||||
|
? <div className="mono" style={{ marginTop: "10px", fontSize: "12px", color: "var(--black-alpha-48)" }}>// 加载素材…</div>
|
||||||
|
: assetPickFiltered.length === 0 && (
|
||||||
|
<div className="mono" style={{ marginTop: "10px", fontSize: "12px", color: "var(--black-alpha-48)" }}>// 暂无可选素材,可直接上传本地图</div>
|
||||||
|
)}
|
||||||
|
{/* 分页:与模特素材库一致(上一页 / X·N 共 / 下一页) */}
|
||||||
|
{assetPickPageCount > 1 && (
|
||||||
|
<div className="hstack" style={{ marginTop: 14, justifyContent: "center", gap: 12 }}>
|
||||||
|
<button className="btn btn-ghost btn-sm" type="button" disabled={assetPickPage <= 1} onClick={() => setAssetPickPage((p) => Math.max(1, p - 1))}>上一页</button>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{assetPickPage} / {assetPickPageCount} · 共 {assetPickFiltered.length}</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" type="button" disabled={assetPickPage >= assetPickPageCount} onClick={() => setAssetPickPage((p) => Math.min(assetPickPageCount, p + 1))}>下一页</button>
|
||||||
</div>
|
</div>
|
||||||
{(productRecord?.images ?? []).length === 0 && (
|
|
||||||
<div className="mono" style={{ marginTop: "10px", fontSize: "12px", color: "var(--black-alpha-48)" }}>// 该商品暂无已有图,可直接上传本地图</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user