feat(core): Wave 3 batch2 — 资产库详情/上传/批量 + AI工具模特库/gen-card/pl-modal
资产库(P0,sub-agent):多选+吸底 bulk-bar、资产详情弹窗(立绘+三视图+标签/属性+商品媒体画廊)、 上传 Drawer→居中 Modal(类型分段+按类型字段+拖拽预览)、缺三视图徽标(复用现成类) AI图片工具(P0,sub-agent):接入现成 ActorLibrary 模特库弹窗、gen-card 三件套(再生成/就地反馈/ 已采用绿角标,采用反馈去全局弹窗改就地)、平台套图恢复多选+分组、商品库 pl-modal 选择器(restraint重写) 验证: tsc 0 · build 0 · 走查 资产详情/上传弹窗开关ESC + 模特库/pl-modal 打开 全过 · 0 console error ⚠️ 真实数据缺(未造假):资产「移动到」改分类需 api.updateAsset 支持 category 字段; 详情三视图版本历史 V1 是 mock 无真实源;平台无 platform 字段→前端组织分批。均移交监督。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0083918207
commit
38f56cb037
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -18,13 +19,15 @@ import {
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Users,
|
||||
WandSparkles,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||
import { api } from "../api";
|
||||
import { ActorLibrary } from "../components/actor-library";
|
||||
import { SkeletonRows } from "../components/loading";
|
||||
import { MediaLightbox, SuccessModal } from "../components/overlays";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
import type { Page } from "./route-config";
|
||||
import { statusPill } from "./stage-config";
|
||||
@@ -487,6 +490,10 @@ type GenBatch = {
|
||||
productTitle?: string;
|
||||
/** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */
|
||||
modelId?: string;
|
||||
/** 该批次选中的模特展示名(导航头显示) */
|
||||
modelName?: string;
|
||||
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
||||
platformIds?: string[];
|
||||
};
|
||||
|
||||
export function ImageWorkbenchPage({
|
||||
@@ -521,12 +528,23 @@ export function ImageWorkbenchPage({
|
||||
const [ratio, setRatio] = useState(meta.ratio);
|
||||
const [style, setStyle] = useState("auto");
|
||||
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
||||
// 行29:模特/平台改单选(原 string[] 多选 → 单个 id;沿用数组结构最小改动,长度恒为 0/1)
|
||||
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
||||
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
||||
// 模特库全屏弹窗(P0①:复用现成 ActorLibrary,mode=replace 选一个回填)
|
||||
const [actorLibOpen, setActorLibOpen] = useState(false);
|
||||
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
|
||||
const [pickedModelName, setPickedModelName] = useState("");
|
||||
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
|
||||
const [plOpen, setPlOpen] = useState(false);
|
||||
const [plDraft, setPlDraft] = useState<string[]>([]);
|
||||
const [plQuery, setPlQuery] = useState("");
|
||||
const [plCat, setPlCat] = useState("");
|
||||
// gen-card 就地反馈(§4.18:采用反馈不走全局弹窗,改卡内 show-feedback 1.5s)
|
||||
const [feedbackKey, setFeedbackKey] = useState("");
|
||||
// 单图「更多」气泡当前展开的 key(点击切换,点空收起)
|
||||
const [openMore, setOpenMore] = useState("");
|
||||
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
|
||||
const [batches, setBatches] = useState<GenBatch[]>([]);
|
||||
// 加入资产库成功提示(行32④)
|
||||
const [adoptNotice, setAdoptNotice] = useState<{ count: number } | null>(null);
|
||||
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
|
||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// 生成结果图片放大预览
|
||||
@@ -578,10 +596,27 @@ export function ImageWorkbenchPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId, mode]);
|
||||
|
||||
// 行29:单选——再点同一张取消,点别张替换(长度恒为 0 或 1)
|
||||
function togglePick(id: string) {
|
||||
// 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组)
|
||||
function togglePick(id: string, name?: string) {
|
||||
if (mode === "cover") {
|
||||
setPickedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
return;
|
||||
}
|
||||
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
|
||||
setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
|
||||
}
|
||||
// 单图就地反馈(§4.18):显示 1.5s 后自动撤掉
|
||||
function flashFeedback(key: string) {
|
||||
setFeedbackKey(key);
|
||||
window.setTimeout(() => setFeedbackKey((k) => (k === key ? "" : k)), 1500);
|
||||
}
|
||||
// 点击空白收起单图「更多」气泡
|
||||
useEffect(() => {
|
||||
if (!openMore) return;
|
||||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".gen-img-more")) setOpenMore(""); };
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [openMore]);
|
||||
|
||||
const ratioVar = ratio.replace(":", " / ");
|
||||
const candidateCount = Math.max(1, Number(count) || 4);
|
||||
@@ -589,7 +624,9 @@ export function ImageWorkbenchPage({
|
||||
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
|
||||
const canGenerate =
|
||||
prompt.trim().length > 0 &&
|
||||
(mode !== "model" || (!!product?.id && pickedIds.length > 0));
|
||||
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
|
||||
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
|
||||
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
|
||||
const anyGenerating = batches.some((b) => b.status === "generating");
|
||||
|
||||
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
|
||||
@@ -605,32 +642,38 @@ export function ImageWorkbenchPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function runGenerate() {
|
||||
if (!canGenerate) return;
|
||||
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
|
||||
async function startBatch(opts: {
|
||||
prompt: string;
|
||||
ratio: string;
|
||||
count: number;
|
||||
productId?: string;
|
||||
productTitle?: string;
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
platformIds?: string[];
|
||||
}) {
|
||||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
prompt: prompt.trim(),
|
||||
ratio,
|
||||
count: candidateCount,
|
||||
prompt: opts.prompt,
|
||||
ratio: opts.ratio,
|
||||
count: opts.count,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now(),
|
||||
productId: product?.id,
|
||||
productTitle: product?.title,
|
||||
modelId: mode === "model" ? pickedIds[0] : undefined
|
||||
productId: opts.productId,
|
||||
productTitle: opts.productTitle,
|
||||
modelId: opts.modelId,
|
||||
modelName: opts.modelName,
|
||||
platformIds: opts.platformIds
|
||||
};
|
||||
// 追加新批次(行32②:不覆盖,在下面新增一行)
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
try {
|
||||
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount, product_id: product?.id, model_id: mode === "model" ? pickedIds[0] : undefined, ratio });
|
||||
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio });
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId
|
||||
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
|
||||
: b
|
||||
);
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
@@ -643,42 +686,44 @@ export function ImageWorkbenchPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function runGenerate() {
|
||||
if (!canGenerate) return;
|
||||
const base = {
|
||||
prompt: prompt.trim(),
|
||||
ratio,
|
||||
count: candidateCount,
|
||||
productId: product?.id,
|
||||
productTitle: product?.title
|
||||
};
|
||||
if (mode === "cover") {
|
||||
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
|
||||
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
|
||||
for (const pid of pickedIds) {
|
||||
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
|
||||
void startBatch({ ...base, prompt: platName ? `${base.prompt},${platName}平台套图版式` : base.prompt, platformIds: [pid] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
void startBatch({
|
||||
...base,
|
||||
modelId: mode === "model" ? pickedIds[0] : undefined,
|
||||
modelName: mode === "model" ? pickedModelName : undefined
|
||||
});
|
||||
}
|
||||
|
||||
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
|
||||
async function rerunBatch(src: GenBatch) {
|
||||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newBatch: GenBatch = {
|
||||
id: batchId,
|
||||
await startBatch({
|
||||
prompt: src.prompt,
|
||||
ratio: src.ratio,
|
||||
count: src.count,
|
||||
status: "generating",
|
||||
results: [],
|
||||
adopted: false,
|
||||
ts: Date.now(),
|
||||
// 重跑归属原批次的商品,而非当前选中商品
|
||||
productId: src.productId ?? product?.id,
|
||||
productTitle: src.productTitle ?? product?.title,
|
||||
modelId: src.modelId
|
||||
};
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
try {
|
||||
const result = await onGenerate({ prompt: src.prompt, mode, count: src.count, product_id: src.productId ?? product?.id, model_id: src.modelId, ratio: src.ratio });
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) =>
|
||||
b.id === batchId
|
||||
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
|
||||
: b
|
||||
);
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
setBatches((prev) => {
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
|
||||
persistBatches(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
modelId: src.modelId,
|
||||
modelName: src.modelName,
|
||||
platformIds: src.platformIds
|
||||
});
|
||||
}
|
||||
|
||||
/* 删除整个批次(行32③:批次气泡"删除当前批次") */
|
||||
@@ -701,31 +746,46 @@ export function ImageWorkbenchPage({
|
||||
});
|
||||
}
|
||||
|
||||
/* 加入资产库 / 取消加入(行32④:批次级来回切 + 成功提示) */
|
||||
/* 加入资产库 / 取消加入(批次级来回切;§4.18:就地反馈,亮在该批首图上,不弹全局窗) */
|
||||
function toggleAdoptBatch(batchId: string) {
|
||||
setBatches((prev) => {
|
||||
const target = prev.find((b) => b.id === batchId);
|
||||
const willAdopt = !target?.adopted;
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||||
persistBatches(next);
|
||||
if (willAdopt && target) setAdoptNotice({ count: target.results.length });
|
||||
const firstId = target?.results[0]?.id;
|
||||
if (willAdopt && firstId) flashFeedback(`${batchId}:${firstId}`);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/* 加入资产库 / 取消(单图级,行32④:单图气泡"加入资产库",来回切) */
|
||||
function toggleAdoptImage(batchId: string, _assetId: string) {
|
||||
// 单图采用以批次级 adopted 表达(后端无单图采用接口),提示单张
|
||||
/* 加入资产库 / 取消(单图级,§4.18:单图气泡"加入资产库",就地反馈不弹全局窗) */
|
||||
function toggleAdoptImage(batchId: string, assetId: string) {
|
||||
// 单图采用以批次级 adopted 表达(后端无单图采用接口),反馈落在被点的那张图上
|
||||
setBatches((prev) => {
|
||||
const target = prev.find((b) => b.id === batchId);
|
||||
const willAdopt = !target?.adopted;
|
||||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||||
persistBatches(next);
|
||||
if (willAdopt) setAdoptNotice({ count: 1 });
|
||||
if (willAdopt) flashFeedback(`${batchId}:${assetId}`);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
|
||||
async function regenSingleImage(src: GenBatch) {
|
||||
await startBatch({
|
||||
prompt: src.prompt,
|
||||
ratio: src.ratio,
|
||||
count: 1,
|
||||
productId: src.productId ?? product?.id,
|
||||
productTitle: src.productTitle ?? product?.title,
|
||||
modelId: src.modelId,
|
||||
modelName: src.modelName,
|
||||
platformIds: src.platformIds
|
||||
});
|
||||
}
|
||||
|
||||
/* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次;
|
||||
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
|
||||
useEffect(() => {
|
||||
@@ -786,7 +846,9 @@ export function ImageWorkbenchPage({
|
||||
|
||||
const hasResults = batches.length > 0;
|
||||
|
||||
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */
|
||||
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
|
||||
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除)
|
||||
② 中央 .gen-image-feedback 就地反馈(采用后亮 1.5s)③ 左上「已采用」绿角标。 */
|
||||
function renderBatchGrid(batch: GenBatch) {
|
||||
const generating = batch.status === "generating";
|
||||
const n = batch.results.length || batch.count;
|
||||
@@ -800,47 +862,67 @@ export function ImageWorkbenchPage({
|
||||
{(batch.results.length
|
||||
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id }))
|
||||
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" }))
|
||||
).map(({ key, index, url, assetId }) => (
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
|
||||
{url ? (
|
||||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||||
) : (
|
||||
<div className="placeholder">
|
||||
{generating ? (
|
||||
// 行30:生成中占位用旋转 spinner icon,不再是「生成中…」文字
|
||||
<span className="gen-loading" aria-label="生成中">
|
||||
<span className="spinner" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{url && (
|
||||
<div className="gen-image-actions">
|
||||
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
{/* 行32③:hover「更多」出气泡菜单(加入资产库 / 删除),不再直接下载 */}
|
||||
<div className="gen-img-more">
|
||||
<button className="gen-img-btn" type="button" title="更多">
|
||||
<MoreHorizontal size={14} />
|
||||
).map(({ key, index, url, assetId }) => {
|
||||
const cellKey = `${batch.id}:${assetId || key}`;
|
||||
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
|
||||
return (
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}${batch.adopted && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
|
||||
{url ? (
|
||||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||||
) : (
|
||||
<div className="placeholder">
|
||||
{generating ? (
|
||||
<span className="gen-loading" aria-label="生成中">
|
||||
<span className="spinner" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* ③ 左上「已采用」绿角标(批次 adopted 时常驻) */}
|
||||
{url && batch.adopted && (
|
||||
<span className="gen-adopt-badge">
|
||||
<Check size={11} />
|
||||
已采用
|
||||
</span>
|
||||
)}
|
||||
{/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */}
|
||||
{url && (
|
||||
<div className="gen-image-feedback" aria-hidden={!showFeedback}>
|
||||
<Check size={20} />
|
||||
<span>已加入资产库</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
|
||||
{url && (
|
||||
<div className="gen-image-actions">
|
||||
<button className="gen-img-btn" type="button" title="再生成" disabled={generating} onClick={() => regenSingleImage(batch)}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<div className="gen-bubble">
|
||||
<button type="button" className="gen-bubble-item" onClick={() => toggleAdoptImage(batch.id, assetId)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeImageFromBatch(batch.id, assetId)}>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
<div className="gen-img-more">
|
||||
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
|
||||
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -873,12 +955,18 @@ export function ImageWorkbenchPage({
|
||||
<span className="k">商品</span>
|
||||
<span className="v">{batchProductTitle(batch)}</span>
|
||||
</div>
|
||||
{mode === "model" && batch.modelName && (
|
||||
<div className="pv-line">
|
||||
<span className="k">模特</span>
|
||||
<span className="v">{batch.modelName}</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))
|
||||
{batch.platformIds && batch.platformIds.length
|
||||
? PLATFORM_OPTIONS.filter((p) => batch.platformIds!.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.join("、")
|
||||
: "未选择"}
|
||||
@@ -933,6 +1021,42 @@ export function ImageWorkbenchPage({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 平台套图 · 多平台分组结果(P0③)──
|
||||
每个平台一个 section 头(平台 logo + 名 + 批次数),其下挂该平台的批次卡。 */
|
||||
function renderCoverGrouped(list: GenBatch[]) {
|
||||
// 按平台 id 分组;无平台标签的批次归到「未分类」组(兜底,正常不出现)
|
||||
const groups: Array<{ pid: string; batches: GenBatch[] }> = [];
|
||||
for (const batch of list) {
|
||||
const pid = batch.platformIds?.[0] || "_";
|
||||
let g = groups.find((x) => x.pid === pid);
|
||||
if (!g) { g = { pid, batches: [] }; groups.push(g); }
|
||||
g.batches.push(batch);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{groups.map((g) => {
|
||||
const plat = PLATFORM_OPTIONS.find((p) => p.id === g.pid);
|
||||
return (
|
||||
<Fragment key={g.pid}>
|
||||
<div className="iw-cover-group-h">
|
||||
{plat ? (
|
||||
<>
|
||||
<span className={`cg-logo p-logo-${plat.id}`}>{plat.logo}</span>
|
||||
<span className="cg-name">{plat.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="cg-name">未分类平台</span>
|
||||
)}
|
||||
<span className="cg-ct">// {g.batches.length} 批</span>
|
||||
</div>
|
||||
{renderBatchCards(g.batches)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
mode === "image" → 对话流形态(基线 image-optimize · design.md §4.13)
|
||||
左会话列表 + 中央 hero/对话流 + 底部 chat 输入栏
|
||||
@@ -1094,13 +1218,6 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<SuccessModal
|
||||
open={!!adoptNotice}
|
||||
title="加入资产库"
|
||||
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
|
||||
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
|
||||
close={() => setAdoptNotice(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1186,6 +1303,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="从商品库选择">
|
||||
<LayoutGrid size={13} />
|
||||
商品库
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
<div className="tb-search-wrap">
|
||||
{searchOpen && (
|
||||
@@ -1225,8 +1347,13 @@ export function ImageWorkbenchPage({
|
||||
<div className="iw-step-h">
|
||||
<span className="num">1</span>
|
||||
<span className="title">选择模特</span>
|
||||
{/* P0①:接入现成模特库全屏弹窗(原「全部模特→」仅内联展开;现可打开完整演员库选用) */}
|
||||
<button className="iw-actor-lib-btn" type="button" onClick={() => setActorLibOpen(true)}>
|
||||
<Users size={12} />
|
||||
模特库
|
||||
</button>
|
||||
{personAssets.length > 6 && (
|
||||
<span className="right" role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部模特 (${personAssets.length}) →`}</span>
|
||||
<span className="right" role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部 (${personAssets.length})`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="model-grid">
|
||||
@@ -1242,7 +1369,7 @@ export function ImageWorkbenchPage({
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
|
||||
onClick={() => togglePick(item.id)}
|
||||
onClick={() => togglePick(item.id, item.name)}
|
||||
>
|
||||
<span className="m-check">
|
||||
<Check size={11} />
|
||||
@@ -1269,7 +1396,7 @@ export function ImageWorkbenchPage({
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
|
||||
onClick={() => togglePick(item.id)}
|
||||
onClick={() => togglePick(item.id, item.name)}
|
||||
>
|
||||
<span className="m-check">
|
||||
<Check size={11} />
|
||||
@@ -1377,21 +1504,72 @@ export function ImageWorkbenchPage({
|
||||
) : (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */}
|
||||
{renderBatchCards(batches)}
|
||||
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头 */}
|
||||
{mode === "cover" ? renderCoverGrouped(batches) : renderBatchCards(batches)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SuccessModal
|
||||
open={!!adoptNotice}
|
||||
title="加入资产库"
|
||||
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
|
||||
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
|
||||
close={() => setAdoptNotice(null)}
|
||||
|
||||
{/* P0①:模特库全屏弹窗(复用现成 ActorLibrary,mode=replace 选一个回填到「选择模特」) */}
|
||||
<ActorLibrary
|
||||
open={actorLibOpen}
|
||||
mode="replace"
|
||||
assets={personAssets}
|
||||
onClose={() => setActorLibOpen(false)}
|
||||
onPick={(assetId, assetName) => {
|
||||
setPickedIds([assetId]);
|
||||
setPickedModelName(assetName || "");
|
||||
void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {});
|
||||
setActorLibOpen(false);
|
||||
}}
|
||||
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
|
||||
onUpload={(file) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("name", file.name);
|
||||
fd.append("asset_type", "image");
|
||||
fd.append("category", "person");
|
||||
return api.uploadAsset(fd).catch(() => null);
|
||||
}}
|
||||
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
|
||||
onRefresh={() => { void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {}); }}
|
||||
/>
|
||||
|
||||
{/* P0④:商品库全屏选择器(pl-modal · 多选 + 右上勾 + 已选汇总 · restraint token) */}
|
||||
{plOpen && (
|
||||
<ProductLibraryModal
|
||||
products={products}
|
||||
draft={plDraft}
|
||||
setDraft={setPlDraft}
|
||||
query={plQuery}
|
||||
setQuery={setPlQuery}
|
||||
cat={plCat}
|
||||
setCat={setPlCat}
|
||||
coverUrl={productCoverUrl}
|
||||
onClose={() => setPlOpen(false)}
|
||||
onConfirm={() => {
|
||||
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
|
||||
if (plDraft.length) {
|
||||
setProductId(plDraft[0]);
|
||||
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
|
||||
for (const pid of plDraft.slice(1)) {
|
||||
const p = products.find((x) => x.id === pid);
|
||||
if (mode === "cover") {
|
||||
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformIds: [platId] });
|
||||
} else {
|
||||
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setPlOpen(false);
|
||||
}}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1441,6 +1619,135 @@ function Pill({
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
P0④ · 商品库全屏选择器(pl-modal · 基线 model-photo.html .pl-modal)
|
||||
左 = 分类侧栏;右 = 搜索 toolbar + 商品多选网格(右上勾)+ 底部已选汇总 + 确定。
|
||||
全 restraint token,不复用 styles.css 旧版 .pl-card(暖米)。
|
||||
════════════════════════════════════════════════ */
|
||||
function ProductLibraryModal({
|
||||
products,
|
||||
draft,
|
||||
setDraft,
|
||||
query,
|
||||
setQuery,
|
||||
cat,
|
||||
setCat,
|
||||
coverUrl,
|
||||
onClose,
|
||||
onConfirm,
|
||||
navigate
|
||||
}: {
|
||||
products: Product[];
|
||||
draft: string[];
|
||||
setDraft: (next: string[]) => void;
|
||||
query: string;
|
||||
setQuery: (v: string) => void;
|
||||
cat: string;
|
||||
setCat: (v: string) => void;
|
||||
coverUrl: (p: Product) => string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
navigate?: (page: Page) => void;
|
||||
}) {
|
||||
// Esc 关闭 + body 滚动锁(与 ActorLibrary 一致体感)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
|
||||
}, [onClose]);
|
||||
|
||||
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
|
||||
const visible = products.filter((p) => {
|
||||
if (cat && p.category !== cat) return false;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return `${p.title} ${p.category || ""} ${p.brand || ""}`.toLowerCase().includes(q);
|
||||
});
|
||||
function toggle(id: string) {
|
||||
setDraft(draft.includes(id) ? draft.filter((x) => x !== id) : [...draft, id]);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="pl-modal-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="pl-modal" role="dialog" aria-modal="true" aria-label="商品库">
|
||||
<div className="pl-modal-h">
|
||||
<h2>商品库</h2>
|
||||
<span className="ct">// 选择商品 · 可多选</span>
|
||||
<div className="actions">
|
||||
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-modal-body">
|
||||
<aside className="pl-side">
|
||||
<div className="pl-side-h">// 分类</div>
|
||||
<div className={`pl-side-item${!cat ? " active" : ""}`} role="button" tabIndex={0} onClick={() => setCat("")}>
|
||||
全部商品
|
||||
<span className="ct">{products.length}</span>
|
||||
</div>
|
||||
{categories.map((c) => (
|
||||
<div className={`pl-side-item${cat === c ? " active" : ""}`} key={c} role="button" tabIndex={0} onClick={() => setCat(c)}>
|
||||
{c}
|
||||
<span className="ct">{products.filter((p) => p.category === c).length}</span>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
<div className="pl-main">
|
||||
<div className="pl-toolbar">
|
||||
<div className="search">
|
||||
<Search size={14} />
|
||||
<input placeholder="搜索商品 / 分类 / 品牌" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
{navigate && (
|
||||
<button className="btn-new" type="button" onClick={() => navigate("productCreateUpload")}>
|
||||
<Plus size={13} />
|
||||
新建商品
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-scroll">
|
||||
{visible.length === 0 ? (
|
||||
<div className="pl-empty">
|
||||
<div className="mono">// NO MATCH</div>
|
||||
<div>没有符合条件的商品</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pl-grid">
|
||||
{visible.map((p) => {
|
||||
const url = coverUrl(p);
|
||||
const on = draft.includes(p.id);
|
||||
return (
|
||||
<div className={`pl-card${on ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => toggle(p.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(p.id); } }}>
|
||||
<span className="pl-check"><Check size={11} /></span>
|
||||
{url ? (
|
||||
<div className="pl-thumb has-real-media"><img src={url} alt={p.title} loading="lazy" /></div>
|
||||
) : (
|
||||
<div className="placeholder pl-thumb"><span className="ph-frame">{p.title.slice(0, 2)}</span></div>
|
||||
)}
|
||||
<div className="pl-name">{p.title}</div>
|
||||
<div className="pl-meta">// {p.category || "未分类"}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-modal-f">
|
||||
<span className="summary">已选 <b>{draft.length}</b> 个商品</span>
|
||||
<button className="btn" type="button" onClick={onClose}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={draft.length === 0} onClick={onConfirm}>确定 ({draft.length})</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
模特图方案对比 Demo(纯静态展示 · 像素还原)
|
||||
variant="A" → public/exact/model-photo-demo-a.html
|
||||
|
||||
Reference in New Issue
Block a user