@@ -20,14 +20,13 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Users,
|
||||
WandSparkles,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
||||
import type { AITask, Asset, CoverProductInfo, CreationRef, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
||||
import { pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { api } from "../api";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
@@ -35,6 +34,7 @@ import { imageModelPickerOptions } from "../model-display";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { SkeletonRows, SystemLoading } from "../components/loading";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
import { RichMentionEditor, type RichMentionEditorHandle } from "../components/rich-mention-editor";
|
||||
import { Pager } from "../components/pager";
|
||||
import { useViewMode } from "../components/use-view-mode";
|
||||
import type { Page } from "./route-config";
|
||||
@@ -454,11 +454,11 @@ const MODE_META: Record<
|
||||
},
|
||||
cover: {
|
||||
title: "平台套图",
|
||||
desc: "根据平台规范生成主图、卖点图、细节图与场景图",
|
||||
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
|
||||
desc: "根据商品信息和商品图生成主图、卖点图、细节图与场景图",
|
||||
// 通用电商主图规范:默认 1:1(不再按平台切换比例)
|
||||
ratio: "1:1",
|
||||
// YYX#4:只出平台头图,去掉「详情排版」
|
||||
promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰`
|
||||
// 仅作兜底文案;实际提交的 prompt 由「商品信息」拼出(composeCoverPrompt)
|
||||
promptTemplate: (title) => `${title},电商主图套图,统一视觉,商品主体清晰`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -479,38 +479,73 @@ const IMAGE_SUGGESTIONS = [
|
||||
{ label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" }
|
||||
];
|
||||
|
||||
/* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg;
|
||||
logo 留作图加载失败时的兜底字符,img 为真 logo) */
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ id: "dy", name: "抖音电商", logo: "抖", img: "/assets/svg/icon-platform-douyin.svg" },
|
||||
{ id: "tb", name: "淘宝", logo: "淘", img: "/assets/svg/icon-platform-taobao.svg" },
|
||||
{ id: "tm", name: "天猫", logo: "猫", img: "/assets/svg/icon-platform-tmall.svg" },
|
||||
{ id: "jd", name: "京东", logo: "京", img: "/assets/svg/icon-platform-jd.svg" },
|
||||
{ id: "pdd", name: "拼多多", logo: "拼", img: "/assets/svg/icon-platform-pdd.svg" },
|
||||
{ id: "xhs", name: "小红书", logo: "红", img: "/assets/svg/icon-platform-xiaohongshu.svg" },
|
||||
{ id: "ks", name: "快手", logo: "快", img: "/assets/svg/icon-platform-kuaishou.svg" },
|
||||
{ id: "sph", name: "视频号", logo: "视", img: "/assets/svg/icon-platform-wechat-video.svg" },
|
||||
{ id: "amz", name: "亚马逊", logo: "a", img: "/assets/svg/icon-platform-amazon.svg" },
|
||||
{ id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" }
|
||||
];
|
||||
|
||||
/* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */
|
||||
/* 前端平台 id(dy/tb…) ↔ 后端规范化 platform_id。平台套图已不再选平台(走通用电商主图规范),
|
||||
这里只保留给旧记录:恢复旧批次时还原平台 key,重跑旧批次时原样带回 platform_id(后端仍兼容)。 */
|
||||
const PLATFORM_ID_MAP: Record<string, string> = {
|
||||
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
|
||||
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
|
||||
};
|
||||
|
||||
/* YYX#row10:平台 logo —— 优先真 logo 图(public/assets/svg),加载失败回退到品牌色块+字符。
|
||||
className 默认 p-logo(平台卡/筛选弹窗),分组头传 cg-logo。 */
|
||||
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
|
||||
const [err, setErr] = useState(false);
|
||||
return (
|
||||
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
|
||||
{p.img && !err
|
||||
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
|
||||
: p.logo}
|
||||
</span>
|
||||
);
|
||||
/* 平台套图 · 商品信息(替代原「画面要求」):用户填写卖点 / 作用 / 人群 / 规格 / 补充,
|
||||
默认从商品资料(selling_points / description / target_audience / specs)带入。 */
|
||||
type CoverInfoDraft = { sellingPoints: string; effect: string; audience: string; specs: string; notes: string };
|
||||
|
||||
function formatProductSpecs(specs?: Record<string, unknown>): string {
|
||||
if (!specs || typeof specs !== "object") return "";
|
||||
return Object.entries(specs)
|
||||
.filter(([, value]) => typeof value === "string" || typeof value === "number")
|
||||
.map(([key, value]) => `${key}:${String(value).trim()}`)
|
||||
.filter((line) => !line.endsWith(":"))
|
||||
.slice(0, 6)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function coverInfoFromProduct(p?: Product): CoverInfoDraft {
|
||||
const points = [...(p?.selling_points || [])]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((sp) => {
|
||||
const title = (sp.title || "").trim();
|
||||
const detail = (sp.detail || "").trim();
|
||||
if (!title) return detail.slice(0, 40);
|
||||
return detail && detail.length <= 40 ? `${title}:${detail}` : title;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return {
|
||||
sellingPoints: points.join("\n"),
|
||||
effect: (p?.description || "").trim().slice(0, 300),
|
||||
audience: (p?.target_audience || "").trim(),
|
||||
specs: formatProductSpecs(p?.specs),
|
||||
notes: ""
|
||||
};
|
||||
}
|
||||
|
||||
/* 与后端 _split_selling_points 同口径:按换行 / 分号拆,只有一行时再按顿号 / 逗号拆,最多 6 条 */
|
||||
function splitSellingPoints(text: string): string[] {
|
||||
const lines = text.split(/[\n\r;;]+/).map((x) => x.replace(/^\s*(?:[-•·*]+|\d+\s*[.、))])\s*/, "").trim()).filter(Boolean);
|
||||
const parts = lines.length <= 1 ? (lines[0] || text).split(/[、,,]+/).map((x) => x.trim()).filter(Boolean) : lines;
|
||||
return parts.slice(0, 6);
|
||||
}
|
||||
|
||||
function coverInfoPayload(d: CoverInfoDraft): CoverProductInfo {
|
||||
const out: CoverProductInfo = {};
|
||||
if (d.sellingPoints.trim()) out.selling_points = d.sellingPoints.trim();
|
||||
if (d.effect.trim()) out.effect = d.effect.trim();
|
||||
if (d.audience.trim()) out.audience = d.audience.trim();
|
||||
if (d.specs.trim()) out.specs = d.specs.trim();
|
||||
if (d.notes.trim()) out.notes = d.notes.trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
/* 可读摘要:作为批次 prompt(批次头展示 / 搜索 / 旧客户端兼容),结构化信息另走 product_info */
|
||||
function composeCoverPrompt(title: string, d: CoverInfoDraft): string {
|
||||
const parts: string[] = [];
|
||||
const points = splitSellingPoints(d.sellingPoints);
|
||||
if (points.length) parts.push(`核心卖点:${points.join("、")}`);
|
||||
if (d.effect.trim()) parts.push(`商品作用:${d.effect.trim()}`);
|
||||
if (d.audience.trim()) parts.push(`适用人群:${d.audience.trim()}`);
|
||||
if (d.specs.trim()) parts.push(`规格:${d.specs.trim()}`);
|
||||
if (d.notes.trim()) parts.push(`补充:${d.notes.trim()}`);
|
||||
return `${title}电商主图 · ${parts.join(";")}`;
|
||||
}
|
||||
|
||||
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。
|
||||
@@ -530,10 +565,14 @@ type GenBatch = {
|
||||
modelId?: string;
|
||||
/** 该批次选中的模特展示名(导航头显示) */
|
||||
modelName?: string;
|
||||
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
||||
/** 旧平台套图记录的平台 id 列表(已不再选平台;仅用于重跑旧批次时原样带回) */
|
||||
platformIds?: string[];
|
||||
/** 平台套图:本批提交的商品信息(重跑 / 补图沿用) */
|
||||
productInfo?: CoverProductInfo;
|
||||
/** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */
|
||||
refs?: { name: string; url: string; assetId?: string }[];
|
||||
/** 图片创作 @ 引用的结构化实体;重跑时仍按实体取事实和参考图。 */
|
||||
mentions?: CreationRef[];
|
||||
/** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */
|
||||
backendBatchId?: string;
|
||||
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
|
||||
@@ -582,7 +621,7 @@ export function ImageWorkbenchPage({
|
||||
modelConfigs: ModelConfig[];
|
||||
onBack: () => void;
|
||||
navigate?: (page: Page) => void;
|
||||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
|
||||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: CreationRef[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
|
||||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||||
/** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */
|
||||
imageProductId?: string;
|
||||
@@ -619,6 +658,15 @@ export function ImageWorkbenchPage({
|
||||
conversationScopeKeyRef.current = conversationScopeKey;
|
||||
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
|
||||
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
|
||||
// 平台套图:商品信息草稿(切换商品时从商品资料重新带入;同一商品的资料刷新不覆盖用户编辑)
|
||||
const [coverInfo, setCoverInfo] = useState<CoverInfoDraft>(() => coverInfoFromProduct(product));
|
||||
useEffect(() => {
|
||||
if (mode === "cover") setCoverInfo(coverInfoFromProduct(product));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, product?.id]);
|
||||
const updateCoverInfo = (key: keyof CoverInfoDraft, value: string) => setCoverInfo((prev) => ({ ...prev, [key]: value }));
|
||||
const coverInfoReady = !!(coverInfo.sellingPoints.trim() || coverInfo.effect.trim());
|
||||
const coverSellingCount = splitSellingPoints(coverInfo.sellingPoints).length;
|
||||
const [ratio, setRatio] = useState(meta.ratio);
|
||||
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
|
||||
const [ratioManual, setRatioManual] = useState(false);
|
||||
@@ -686,17 +734,23 @@ export function ImageWorkbenchPage({
|
||||
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
|
||||
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
|
||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// 图片创作 @ 引用保留实体 id;后端再回库取卖点和真实参考图,不依赖名字匹配。
|
||||
const [mentionRefs, setMentionRefs] = useState<CreationRef[]>([]);
|
||||
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
|
||||
const [mentionLoading, setMentionLoading] = useState(false);
|
||||
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
|
||||
const [mentionTypeLabels, setMentionTypeLabels] = useState<Record<string, string>>({});
|
||||
const mentionEditorRef = useRef<RichMentionEditorHandle>(null);
|
||||
const mentionMenuRef = useRef<HTMLDivElement>(null);
|
||||
// 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22)
|
||||
const resultsEndRef = useRef<HTMLDivElement | null>(null);
|
||||
// 生成结果图片放大预览
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
// 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用;
|
||||
// row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除)
|
||||
// 平台筛选弹窗已随「选择平台」一起移除。
|
||||
const [gridQuery, setGridQuery] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
// YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛)
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [platformFilter, setPlatformFilter] = useState<string[]>([]);
|
||||
const visibleProducts = products.filter((item) => !(mode === "model" && isLocalLife(item)));
|
||||
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
|
||||
const productCoverUrl = (p: Product): string => {
|
||||
@@ -718,6 +772,49 @@ export function ImageWorkbenchPage({
|
||||
|
||||
const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") });
|
||||
|
||||
const mentionName = (ref: CreationRef) => ref.name.split(" · ")[0].trim();
|
||||
const openImageMentions = useCallback(async () => {
|
||||
setMentionMenuOpen(true);
|
||||
setMentionLoading(true);
|
||||
try {
|
||||
const result = await api.searchMentions({ limit: 6 });
|
||||
setMentionResults(result.results);
|
||||
setMentionTypeLabels(result.type_labels);
|
||||
} catch (error) {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "引用素材加载失败");
|
||||
setMentionResults([]);
|
||||
} finally {
|
||||
setMentionLoading(false);
|
||||
}
|
||||
}, [onNotify]);
|
||||
|
||||
const selectImageMention = (ref: CreationRef) => {
|
||||
setMentionRefs((prev) => prev.some((item) => item.type === ref.type && item.id === ref.id) ? prev : [...prev, ref]);
|
||||
mentionEditorRef.current?.insertMention(ref, true);
|
||||
setMentionMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleStudioPromptChange = (value: string) => {
|
||||
setPrompt(value);
|
||||
// 引用标签被用户删除时,同步解除结构化引用,避免看不见的素材仍参与生成。
|
||||
setMentionRefs((prev) => prev.filter((ref) => value.includes(`@${mentionName(ref)}`)));
|
||||
};
|
||||
|
||||
const mentionGroups = useMemo(() => {
|
||||
const groups = new Map<string, CreationRef[]>();
|
||||
mentionResults.forEach((ref) => groups.set(ref.type, [...(groups.get(ref.type) || []), ref]));
|
||||
return Array.from(groups.entries());
|
||||
}, [mentionResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mentionMenuOpen) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!mentionMenuRef.current?.contains(event.target as Node)) setMentionMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [mentionMenuOpen]);
|
||||
|
||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
|
||||
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||
@@ -790,26 +887,17 @@ export function ImageWorkbenchPage({
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [openMore]);
|
||||
// YYX#12:点空白收起平台筛选弹窗
|
||||
useEffect(() => {
|
||||
if (!filterOpen) return;
|
||||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".iw-filter-wrap")) setFilterOpen(false); };
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [filterOpen]);
|
||||
|
||||
const ratioVar = ratio.replace(":", " / ");
|
||||
const candidateCount = Math.max(1, Number(count) || 4);
|
||||
// PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。
|
||||
// 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。
|
||||
const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount;
|
||||
// 平台套图已不再按平台拆批:每次提交一批 candidateCount 张,实扣 = candidateCount × 单价(三种模式同口径)。
|
||||
const estimateCount = candidateCount;
|
||||
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
|
||||
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
|
||||
const canGenerate =
|
||||
prompt.trim().length > 0 &&
|
||||
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
|
||||
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
|
||||
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
|
||||
// 平台套图:必须选商品(商品图作参考)+ 至少填写核心卖点或商品作用之一
|
||||
const canGenerate = mode === "cover"
|
||||
? !!product?.id && coverInfoReady
|
||||
: prompt.trim().length > 0 && (mode !== "model" || (!!product?.id && pickedIds.length > 0));
|
||||
|
||||
/* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、
|
||||
工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect),
|
||||
@@ -919,6 +1007,7 @@ export function ImageWorkbenchPage({
|
||||
productId: first.product_id || undefined,
|
||||
modelId: first.model_id || undefined,
|
||||
platformIds: platformKey ? [platformKey] : undefined,
|
||||
productInfo: first.product_info || undefined,
|
||||
backendBatchId: first.batch_id || undefined,
|
||||
taskIds: live.map((t) => t.id),
|
||||
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
|
||||
@@ -976,6 +1065,8 @@ export function ImageWorkbenchPage({
|
||||
setActiveConvId(conv.id);
|
||||
setBatches([]);
|
||||
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
|
||||
setMentionRefs([]);
|
||||
setMentionMenuOpen(false);
|
||||
setPickedIds([]);
|
||||
} catch (err) {
|
||||
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
|
||||
@@ -1053,6 +1144,8 @@ export function ImageWorkbenchPage({
|
||||
setActiveConvId("");
|
||||
setConversations([]);
|
||||
setBatches([]);
|
||||
setMentionRefs([]);
|
||||
setMentionMenuOpen(false);
|
||||
setRenamingId("");
|
||||
setConvError("");
|
||||
void loadConversations();
|
||||
@@ -1069,10 +1162,14 @@ export function ImageWorkbenchPage({
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
platformIds?: string[];
|
||||
/** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */
|
||||
/** 旧记录兼容:规范化平台 id(douyin/taobao…),仅重跑旧平台批次时带回 */
|
||||
platformId?: string;
|
||||
/** 平台套图:商品信息(卖点 / 作用 / 人群 / 规格 / 补充),透传后端注入主图提示词 */
|
||||
productInfo?: CoverProductInfo;
|
||||
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
|
||||
refs?: { name: string; url: string; file?: File; assetId?: string }[];
|
||||
/** 图片创作 @ 引用的实体;后端根据 id 解析为素材事实和参考图。 */
|
||||
mentions?: CreationRef[];
|
||||
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
|
||||
reuseBatchId?: string;
|
||||
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
|
||||
@@ -1105,8 +1202,10 @@ export function ImageWorkbenchPage({
|
||||
modelId: opts.modelId,
|
||||
modelName: opts.modelName,
|
||||
platformIds: opts.platformIds,
|
||||
productInfo: opts.productInfo,
|
||||
// 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId)
|
||||
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId }))
|
||||
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId })),
|
||||
mentions: opts.mentions
|
||||
};
|
||||
setBatches((prev) => [...prev, newBatch]);
|
||||
}
|
||||
@@ -1135,7 +1234,7 @@ export function ImageWorkbenchPage({
|
||||
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
|
||||
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
|
||||
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
|
||||
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
|
||||
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, product_info: opts.productInfo, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, mention_refs: opts.mentions, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
|
||||
onSubmitted: (taskIds, submittedBatchId) => {
|
||||
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
||||
setBatches((prev) => prev.map((b) => {
|
||||
@@ -1210,11 +1309,13 @@ export function ImageWorkbenchPage({
|
||||
productTitle: mode === "image" ? imageProduct?.title : product?.title
|
||||
};
|
||||
if (mode === "cover") {
|
||||
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
|
||||
// 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束)
|
||||
for (const pid of pickedIds) {
|
||||
void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] });
|
||||
}
|
||||
// 不再选平台:一次提交一批,结构化商品信息走 product_info(后端注入电商主图提示词),
|
||||
// prompt 为可读摘要(批次头展示 / 搜索用)。商品图参考链路不变(后端按 product_id 取商品图)。
|
||||
void startBatch({
|
||||
...base,
|
||||
prompt: composeCoverPrompt(product?.title || "商品", coverInfo),
|
||||
productInfo: coverInfoPayload(coverInfo)
|
||||
});
|
||||
return;
|
||||
}
|
||||
void startBatch({
|
||||
@@ -1222,11 +1323,12 @@ export function ImageWorkbenchPage({
|
||||
modelId: mode === "model" ? pickedIds[0] : undefined,
|
||||
modelName: mode === "model" ? pickedModelName : undefined,
|
||||
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
|
||||
refs: mode === "image" && refImages.length ? refImages : undefined
|
||||
refs: mode === "image" && refImages.length ? refImages : undefined,
|
||||
mentions: mode === "image" && mentionRefs.length ? mentionRefs : undefined
|
||||
});
|
||||
// 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。
|
||||
// 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。
|
||||
if (mode === "image") { setRefImages([]); setPrompt(""); }
|
||||
if (mode === "image") { setRefImages([]); setMentionRefs([]); setPrompt(""); }
|
||||
// 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。
|
||||
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
|
||||
}
|
||||
@@ -1244,8 +1346,10 @@ export function ImageWorkbenchPage({
|
||||
modelName: src.modelName,
|
||||
platformIds: src.platformIds,
|
||||
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
||||
productInfo: src.productInfo,
|
||||
// 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录
|
||||
refs: src.refs,
|
||||
mentions: src.mentions,
|
||||
batchId: src.backendBatchId,
|
||||
reuseBatchId: src.id
|
||||
});
|
||||
@@ -1321,6 +1425,7 @@ export function ImageWorkbenchPage({
|
||||
modelName: src.modelName,
|
||||
platformIds: src.platformIds,
|
||||
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
||||
productInfo: src.productInfo,
|
||||
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
|
||||
refs: src.refs,
|
||||
batchId: src.backendBatchId,
|
||||
@@ -1381,12 +1486,8 @@ export function ImageWorkbenchPage({
|
||||
const q = gridQuery.trim().toLowerCase();
|
||||
list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q));
|
||||
}
|
||||
// YYX#12:筛选弹窗按平台筛选已生成的图(选了哪些平台就只看哪些)
|
||||
if (mode === "cover" && platformFilter.length) {
|
||||
list = list.filter((b) => (b.platformIds || []).some((p) => platformFilter.includes(p)));
|
||||
}
|
||||
return list;
|
||||
}, [batches, productId, mode, gridQuery, platformFilter]);
|
||||
}, [batches, productId, mode, gridQuery]);
|
||||
const hasResults = productBatches.length > 0;
|
||||
|
||||
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
|
||||
@@ -1533,42 +1634,6 @@ 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 ? (
|
||||
<>
|
||||
<PlatformLogo p={plat} className="cg-logo" />
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const featuredProducts = (() => {
|
||||
const selected = visibleProducts.find((item) => item.id === productId);
|
||||
const rest = visibleProducts.filter((item) => item.id !== productId);
|
||||
@@ -1579,7 +1644,6 @@ export function ImageWorkbenchPage({
|
||||
const rest = personAssets.filter((item) => item.id !== pickedIds[0]);
|
||||
return (selected ? [selected, ...rest] : rest).slice(0, 3);
|
||||
})();
|
||||
const selectedPlatform = PLATFORM_OPTIONS.find((item) => item.id === pickedIds[0]);
|
||||
const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS;
|
||||
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
|
||||
const openProductLibrary = () => {
|
||||
@@ -1758,12 +1822,40 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
className="studio-prompt"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
|
||||
/>
|
||||
<div className="studio-prompt-wrap" ref={mentionMenuRef}>
|
||||
<RichMentionEditor
|
||||
ref={mentionEditorRef}
|
||||
className="studio-prompt"
|
||||
value={prompt}
|
||||
refs={mentionRefs}
|
||||
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
|
||||
ariaLabel="图片创作提示词"
|
||||
onChange={handleStudioPromptChange}
|
||||
onAtTrigger={() => { void openImageMentions(); }}
|
||||
/>
|
||||
{mentionMenuOpen ? (
|
||||
<div className="studio-mention-menu" role="dialog" aria-label="引用素材">
|
||||
<div className="studio-mention-head">引用素材</div>
|
||||
<div className="studio-mention-list">
|
||||
{mentionLoading ? (
|
||||
<span className="studio-mention-state">正在加载素材…</span>
|
||||
) : mentionGroups.length === 0 ? (
|
||||
<span className="studio-mention-state">暂无可引用的商品、模特或素材</span>
|
||||
) : mentionGroups.map(([type, refs]) => (
|
||||
<div className="studio-mention-group" key={type}>
|
||||
<span>{mentionTypeLabels[type] || type}</span>
|
||||
{refs.map((ref) => (
|
||||
<button type="button" key={`${ref.type}:${ref.id}`} onClick={() => selectImageMention(ref)}>
|
||||
{ref.cover ? <img src={ref.cover} alt="" /> : <i>{(mentionTypeLabels[ref.type] || ref.type).slice(0, 1)}</i>}
|
||||
<b>{mentionName(ref)}</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-composer-footer">
|
||||
<div className="image-composer-options">
|
||||
@@ -1831,7 +1923,7 @@ export function ImageWorkbenchPage({
|
||||
|
||||
<div className="generator-layout">
|
||||
<aside className="generator-panel">
|
||||
<div className="asset-pair-grid">
|
||||
<div className={`asset-pair-grid${mode === "cover" ? " single" : ""}`}>
|
||||
<section className="control-section">
|
||||
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
|
||||
<div className="product-choice-list">
|
||||
@@ -1866,7 +1958,7 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{mode === "model" ? (
|
||||
{mode === "model" && (
|
||||
<section className="control-section">
|
||||
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
|
||||
<div className="model-choice-grid">
|
||||
@@ -1895,33 +1987,24 @@ export function ImageWorkbenchPage({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="control-section">
|
||||
<div className="control-heading"><span className="step-number">2</span><strong>选择平台</strong></div>
|
||||
<div className="yz-platform-grid">
|
||||
{PLATFORM_OPTIONS.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`platform-choice ${pickedIds.includes(item.id) ? "active" : ""}`}
|
||||
onClick={() => togglePick(item.id)}
|
||||
>
|
||||
<span className="choice-check" />
|
||||
<PlatformLogo p={item} />
|
||||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className={`control-section generator-settings${mode === "cover" ? " platform-generator-settings" : ""}`}>
|
||||
<div className="control-heading"><span className="step-number">3</span><strong>生成设置</strong></div>
|
||||
<section className={`control-section generator-settings${mode === "cover" ? " cover-generator-settings" : ""}`}>
|
||||
<div className="control-heading">
|
||||
<span className="step-number">{mode === "cover" ? 2 : 3}</span>
|
||||
<strong>生成设置</strong>
|
||||
{mode === "cover" && product && (
|
||||
<button type="button" className="cover-info-reset" title="用商品库中的卖点、描述、人群与规格重新填充" onClick={() => setCoverInfo(coverInfoFromProduct(product))}>
|
||||
<RefreshCw size={12} />
|
||||
重新带入
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<div className="field-label">
|
||||
<span>生成数量</span>
|
||||
<small>{mode === "cover" ? "覆盖不同图位" : "单次最多 4 张"}</small>
|
||||
<small>{mode === "cover" ? "依次覆盖主图 / 卖点 / 细节 / 场景" : "单次最多 4 张"}</small>
|
||||
</div>
|
||||
<div className="choice-row">
|
||||
{countOptions.map((value) => (
|
||||
@@ -1983,35 +2066,107 @@ export function ImageWorkbenchPage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="field-group">
|
||||
<label className="field-label">
|
||||
<span>{mode === "cover" ? "画面要求" : "提示词"}</span>
|
||||
<small>{mode === "cover" ? "平台规范已自动载入" : "可继续修改"}</small>
|
||||
</label>
|
||||
<textarea
|
||||
className="image-prompt"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
|
||||
/>
|
||||
<div className="model-line">
|
||||
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
|
||||
<span>
|
||||
{mode === "cover"
|
||||
? (selectedPlatform ? `${selectedPlatform.name}规范` : "请选择平台")
|
||||
: "自动匹配商品参考图"}
|
||||
</span>
|
||||
{mode === "cover" ? (
|
||||
<>
|
||||
{/* 商品信息(替代原「画面要求」):结构化提交 product_info,后端据此生成电商主图 */}
|
||||
<div className="field-group half">
|
||||
<label className="field-label" htmlFor="cover-info-selling">
|
||||
<span>核心卖点</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="cover-info-selling"
|
||||
className="image-prompt cover-info-textarea"
|
||||
value={coverInfo.sellingPoints}
|
||||
onChange={(event) => updateCoverInfo("sellingPoints", event.target.value)}
|
||||
placeholder={"例如:\n72 小时长效保湿\n无酒精,敏感肌可用"}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group half">
|
||||
<label className="field-label" htmlFor="cover-info-effect">
|
||||
<span>商品功效</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="cover-info-effect"
|
||||
className="image-prompt cover-info-textarea"
|
||||
value={coverInfo.effect}
|
||||
onChange={(event) => updateCoverInfo("effect", event.target.value)}
|
||||
placeholder="例如:深层补水、舒缓干燥紧绷,让上妆更服帖"
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group half">
|
||||
<label className="field-label" htmlFor="cover-info-audience">
|
||||
<span>适用人群</span>
|
||||
</label>
|
||||
<input
|
||||
id="cover-info-audience"
|
||||
className="cover-info-input"
|
||||
value={coverInfo.audience}
|
||||
onChange={(event) => updateCoverInfo("audience", event.target.value)}
|
||||
placeholder="例如:干皮 / 敏感肌、熬夜人群"
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group half">
|
||||
<label className="field-label" htmlFor="cover-info-specs">
|
||||
<span>规格参数</span>
|
||||
</label>
|
||||
<input
|
||||
id="cover-info-specs"
|
||||
className="cover-info-input"
|
||||
value={coverInfo.specs}
|
||||
onChange={(event) => updateCoverInfo("specs", event.target.value)}
|
||||
placeholder="例如:200ml · 玻璃瓶装"
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label className="field-label" htmlFor="cover-info-notes">
|
||||
<span>补充要求</span>
|
||||
</label>
|
||||
<input
|
||||
id="cover-info-notes"
|
||||
className="cover-info-input"
|
||||
value={coverInfo.notes}
|
||||
onChange={(event) => updateCoverInfo("notes", event.target.value)}
|
||||
placeholder="例如:清透蓝白色调,浴室台面场景"
|
||||
/>
|
||||
<div className="model-line">
|
||||
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
|
||||
<span className={coverInfoReady ? "" : "cover-info-hint"}>
|
||||
{!product
|
||||
? "请先选择商品"
|
||||
: coverInfoReady
|
||||
? `引用商品图 ${product.images?.length || 0} 张 · 1:1 电商主图`
|
||||
: "补充卖点后即可生成"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="field-group">
|
||||
<label className="field-label">
|
||||
<span>提示词</span>
|
||||
<small>可继续修改</small>
|
||||
</label>
|
||||
<textarea
|
||||
className="image-prompt"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
|
||||
/>
|
||||
<div className="model-line">
|
||||
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
|
||||
<span>自动匹配商品参考图</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="generator-footer">
|
||||
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
|
||||
<WandSparkles />
|
||||
<span>{mode === "cover" ? "生成平台套图" : "立即生成"}</span>
|
||||
<span>{mode === "cover" ? "生成套图" : "立即生成"}</span>
|
||||
</button>
|
||||
<div className="cost-line">
|
||||
<span>{mode === "cover" ? "生成成功后按实际数量结算" : "仅在生成成功后扣费"}</span>
|
||||
<span>成功后扣费</span>
|
||||
<strong>预计 {estimateFee(estimateCount)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2021,21 +2176,11 @@ export function ImageWorkbenchPage({
|
||||
<div className="result-topbar">
|
||||
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
|
||||
<div className="result-topbar-tools">
|
||||
<div className="result-tags">
|
||||
{mode === "cover" ? (
|
||||
<>
|
||||
<span>{selectedPlatform?.name || "未选平台"}</span>
|
||||
<span>{count} 个图位</span>
|
||||
<span>统一视觉</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{ratio}</span>
|
||||
<span>{count} 张</span>
|
||||
<span>自动保存至成品库</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!searchOpen && (
|
||||
<div className="result-tags">
|
||||
<span>{mode === "cover" ? `${count} 张套图` : `${ratio} · ${count} 张`}</span>
|
||||
</div>
|
||||
)}
|
||||
{mode === "cover" && (
|
||||
<>
|
||||
<div className="tb-search-wrap">
|
||||
@@ -2053,43 +2198,17 @@ export function ImageWorkbenchPage({
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`tb-menu-wrap chip-wrap iw-filter-wrap${filterOpen ? " open" : ""}`} data-filter="platform">
|
||||
<button className={`search-btn${platformFilter.length ? " active" : ""}`} type="button" title="按平台筛选" onClick={() => setFilterOpen((v) => !v)}>
|
||||
<SlidersHorizontal size={14} />
|
||||
{platformFilter.length > 0 && <span className="iw-filter-count">{platformFilter.length}</span>}
|
||||
</button>
|
||||
<div className="chip-menu align-right iw-filter-menu">
|
||||
<div className="iw-filter-h">
|
||||
<span className="mono">按平台筛选</span>
|
||||
<button type="button" className="iw-filter-clear" disabled={!platformFilter.length} onClick={() => setPlatformFilter([])}>清空</button>
|
||||
</div>
|
||||
<div className="iw-filter-grid">
|
||||
{PLATFORM_OPTIONS.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.id}
|
||||
className={`iw-filter-opt${platformFilter.includes(p.id) ? " on" : ""}`}
|
||||
onClick={() => setPlatformFilter((prev) => (prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id]))}
|
||||
>
|
||||
<PlatformLogo p={p} />
|
||||
<span className="nm">{p.name}</span>
|
||||
<Check className="ck" size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
|
||||
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
|
||||
{mode === "cover" && gridQuery.trim() && (
|
||||
<div className="iw-filter-notice">
|
||||
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
|
||||
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
|
||||
<span className="ifn-text">当前已开启搜索,部分内容可能隐藏</span>
|
||||
<button type="button" className="ifn-clear" onClick={() => { setGridQuery(""); setSearchOpen(false); }}>
|
||||
<X size={12} />
|
||||
清空筛选
|
||||
清空搜索
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2099,33 +2218,35 @@ export function ImageWorkbenchPage({
|
||||
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
|
||||
<p>
|
||||
{mode === "cover"
|
||||
? "选择平台后,系统会按对应图位和尺寸生成一组可继续编辑的电商素材。"
|
||||
? "填写商品卖点与作用后,系统会结合商品图生成主图、卖点图、细节图与场景图。"
|
||||
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
|
||||
{renderBatchCards(productBatches)}
|
||||
<div ref={resultsEndRef} aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="result-footer">
|
||||
{mode === "cover" ? (
|
||||
<>
|
||||
<div className="result-stat"><span>主图</span><strong>白底 / 场景各 1 张</strong></div>
|
||||
<div className="result-stat"><span>卖点证明</span><strong>2 个核心卖点</strong></div>
|
||||
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
|
||||
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
|
||||
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasResults && (
|
||||
<div className="result-footer">
|
||||
{mode === "cover" ? (
|
||||
<>
|
||||
<div className="result-stat"><span>图位</span><strong>主图 / 卖点 / 细节 / 场景</strong></div>
|
||||
<div className="result-stat"><span>卖点依据</span><strong>{coverSellingCount ? `${coverSellingCount} 个核心卖点` : coverInfo.effect.trim() ? "商品功效" : "待填写"}</strong></div>
|
||||
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
|
||||
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
|
||||
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2167,14 +2288,16 @@ export function ImageWorkbenchPage({
|
||||
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()) {
|
||||
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : true) && (mode === "cover" || 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, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
|
||||
const info = { ...coverInfoFromProduct(p), notes: coverInfo.notes };
|
||||
void startBatch({ prompt: composeCoverPrompt(p?.title || "商品", info), ratio, count: candidateCount, productId: pid, productTitle: p?.title, productInfo: coverInfoPayload(info) });
|
||||
} 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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user