|
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
|
|
TableOutlined,
|
|
|
|
|
VideoCameraOutlined,
|
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
|
|
|
|
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
|
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
import { useTypewriter } from "../../hooks/useTypewriter";
|
|
|
|
|
import "../../styles/pages/ecommerce.css";
|
|
|
|
@@ -363,10 +363,13 @@ interface CloneSavedSetting {
|
|
|
|
|
requirement: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EcommerceHistoryRecord {
|
|
|
|
|
type EcommerceHistoryStatus = "generating" | "done" | "failed";
|
|
|
|
|
|
|
|
|
|
interface EcommerceHistoryTurn {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
status: EcommerceHistoryStatus;
|
|
|
|
|
errorMessage?: string;
|
|
|
|
|
output: CloneOutputKey;
|
|
|
|
|
platform: string;
|
|
|
|
|
market: string;
|
|
|
|
@@ -383,6 +386,29 @@ interface EcommerceHistoryRecord {
|
|
|
|
|
replicateLevel: CloneReplicateLevelKey;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EcommerceHistoryRecord {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
status?: EcommerceHistoryStatus;
|
|
|
|
|
errorMessage?: string;
|
|
|
|
|
output: CloneOutputKey;
|
|
|
|
|
platform: string;
|
|
|
|
|
market: string;
|
|
|
|
|
language: string;
|
|
|
|
|
ratio: string;
|
|
|
|
|
requirement: string;
|
|
|
|
|
productImages: CloneImageItem[];
|
|
|
|
|
results: CloneResult[];
|
|
|
|
|
setResultImages: string[];
|
|
|
|
|
setCounts: Record<CloneSetCountKey, number>;
|
|
|
|
|
detailModules: string[];
|
|
|
|
|
modelScenes: string[];
|
|
|
|
|
referenceImages: CloneImageItem[];
|
|
|
|
|
replicateLevel: CloneReplicateLevelKey;
|
|
|
|
|
turns?: EcommerceHistoryTurn[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProductSetPreviewSelection {
|
|
|
|
|
src: string;
|
|
|
|
|
label: string;
|
|
|
|
@@ -1433,9 +1459,67 @@ function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[]
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
|
|
|
|
|
function getTurnResults(turn: EcommerceHistoryTurn): CloneResult[] {
|
|
|
|
|
if (turn.results?.length) return turn.results.filter((item) => item.src);
|
|
|
|
|
if (turn.output !== "set") return [];
|
|
|
|
|
return (turn.setResultImages ?? [])
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map((src, index) => ({ id: `${turn.id}-set-${index}`, src, label: `套图 ${index + 1}` }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): EcommerceHistoryTurn {
|
|
|
|
|
return {
|
|
|
|
|
id: `${record.id}-turn-initial`,
|
|
|
|
|
createdAt: record.createdAt,
|
|
|
|
|
status: record.status ?? "done",
|
|
|
|
|
errorMessage: record.status === "failed" ? record.errorMessage : undefined,
|
|
|
|
|
output: record.output,
|
|
|
|
|
platform: record.platform,
|
|
|
|
|
market: record.market,
|
|
|
|
|
language: record.language,
|
|
|
|
|
ratio: record.ratio,
|
|
|
|
|
requirement: record.requirement,
|
|
|
|
|
productImages: record.productImages ?? [],
|
|
|
|
|
results: record.results ?? [],
|
|
|
|
|
setResultImages: record.setResultImages ?? [],
|
|
|
|
|
setCounts: record.setCounts ?? defaultCloneSetCounts,
|
|
|
|
|
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
|
|
|
|
|
modelScenes: record.modelScenes ?? [],
|
|
|
|
|
referenceImages: record.referenceImages ?? [],
|
|
|
|
|
replicateLevel: record.replicateLevel ?? "high",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallback: EcommerceHistoryRecord, index: number): EcommerceHistoryTurn {
|
|
|
|
|
const status = turn.status ?? fallback.status ?? "done";
|
|
|
|
|
return {
|
|
|
|
|
id: typeof turn.id === "string" && turn.id ? turn.id : `${fallback.id}-turn-${index + 1}`,
|
|
|
|
|
createdAt: typeof turn.createdAt === "number" ? turn.createdAt : fallback.createdAt,
|
|
|
|
|
status,
|
|
|
|
|
errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined,
|
|
|
|
|
output: turn.output ?? fallback.output,
|
|
|
|
|
platform: turn.platform ?? fallback.platform,
|
|
|
|
|
market: turn.market ?? fallback.market,
|
|
|
|
|
language: turn.language ?? fallback.language,
|
|
|
|
|
ratio: turn.ratio ?? fallback.ratio,
|
|
|
|
|
requirement: turn.requirement ?? fallback.requirement,
|
|
|
|
|
productImages: removeFilePayloadFromImages(Array.isArray(turn.productImages) ? turn.productImages : fallback.productImages),
|
|
|
|
|
results: Array.isArray(turn.results) ? turn.results.filter(isCloneResult) : [],
|
|
|
|
|
setResultImages: Array.isArray(turn.setResultImages) ? turn.setResultImages.filter(Boolean) : [],
|
|
|
|
|
setCounts: turn.setCounts ?? fallback.setCounts ?? defaultCloneSetCounts,
|
|
|
|
|
detailModules: turn.detailModules ?? fallback.detailModules ?? defaultCloneDetailModuleIds,
|
|
|
|
|
modelScenes: turn.modelScenes ?? fallback.modelScenes ?? [],
|
|
|
|
|
referenceImages: removeFilePayloadFromImages(Array.isArray(turn.referenceImages) ? turn.referenceImages : fallback.referenceImages ?? []),
|
|
|
|
|
replicateLevel: turn.replicateLevel ?? fallback.replicateLevel ?? "high",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
|
|
|
|
|
const status = record.status ?? "done";
|
|
|
|
|
const baseRecord = {
|
|
|
|
|
...record,
|
|
|
|
|
status,
|
|
|
|
|
errorMessage: status === "failed" ? record.errorMessage : undefined,
|
|
|
|
|
productImages: removeFilePayloadFromImages(record.productImages),
|
|
|
|
|
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
|
|
|
|
results: record.results ?? [],
|
|
|
|
@@ -1445,6 +1529,14 @@ function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): Ecomme
|
|
|
|
|
modelScenes: record.modelScenes ?? [],
|
|
|
|
|
replicateLevel: record.replicateLevel ?? "high",
|
|
|
|
|
};
|
|
|
|
|
const rawTurns = Array.isArray(record.turns) && record.turns.length
|
|
|
|
|
? record.turns
|
|
|
|
|
: [buildHistoryTurnFromRecord(baseRecord)];
|
|
|
|
|
const turns = rawTurns.map((turn, index) => normalizeEcommerceHistoryTurn(turn, baseRecord, index));
|
|
|
|
|
return {
|
|
|
|
|
...baseRecord,
|
|
|
|
|
turns,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readEcommerceHistoryRecords() {
|
|
|
|
@@ -1982,6 +2074,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
const historyRefreshLockRef = useRef(false);
|
|
|
|
|
const lastSavedHistorySignatureRef = useRef("");
|
|
|
|
|
const imageAbortRef = useRef({ current: false });
|
|
|
|
|
const activeHistoryTurnIdRef = useRef<string | null>(null);
|
|
|
|
|
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
|
const lastFailedActionRef = useRef<(() => void) | null>(null);
|
|
|
|
|
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
|
|
|
|
@@ -2113,7 +2206,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
});
|
|
|
|
|
lastFailedActionRef.current = null;
|
|
|
|
|
if (productSetStatus === "generating") setProductSetStatus("idle");
|
|
|
|
|
if (status === "generating") setStatus("idle");
|
|
|
|
|
if (status === "generating") {
|
|
|
|
|
setStatus("idle");
|
|
|
|
|
if (activeHistoryRecordId) {
|
|
|
|
|
const turnId = activeHistoryTurnIdRef.current;
|
|
|
|
|
if (turnId) {
|
|
|
|
|
updateLocalEcommerceHistoryTurn(activeHistoryRecordId, turnId, (turn) => ({
|
|
|
|
|
...turn,
|
|
|
|
|
status: "failed",
|
|
|
|
|
errorMessage: "已取消生成",
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
updateLocalEcommerceHistoryRecord(activeHistoryRecordId, (record) => ({
|
|
|
|
|
...record,
|
|
|
|
|
status: "failed",
|
|
|
|
|
errorMessage: "已取消生成",
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (detailStatus === "generating") setDetailStatus("idle");
|
|
|
|
|
if (tryOnStatus === "generating") setTryOnStatus("idle");
|
|
|
|
|
if (tryOnStatus === "modeling") setTryOnStatus("ready");
|
|
|
|
@@ -4118,27 +4229,50 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
setIsCommandComposerCompact(true);
|
|
|
|
|
imageAbortRef.current = { current: false };
|
|
|
|
|
lastFailedActionRef.current = null;
|
|
|
|
|
setGenerationProgress(0);
|
|
|
|
|
setResults([]);
|
|
|
|
|
setProductSetResultImages([]);
|
|
|
|
|
const pendingGeneration = beginEcommerceHistoryTurn();
|
|
|
|
|
const pendingRecordId = pendingGeneration.record.id;
|
|
|
|
|
const pendingTurnId = pendingGeneration.turn.id;
|
|
|
|
|
setPreviewZoom(1);
|
|
|
|
|
setPreviewOffset({ x: 0, y: 0 });
|
|
|
|
|
previewOffsetRef.current = { x: 0, y: 0 };
|
|
|
|
|
if (cloneOutput === "set") {
|
|
|
|
|
void generateSetImages(
|
|
|
|
|
productImages, cloneSetCounts, requirement,
|
|
|
|
|
platform, ratio, language, market,
|
|
|
|
|
(s) => setStatus(s as ProductCloneStatus),
|
|
|
|
|
(s) => {
|
|
|
|
|
setStatus(s as ProductCloneStatus);
|
|
|
|
|
if (s === "generating") {
|
|
|
|
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined }));
|
|
|
|
|
} else if (s === "failed") {
|
|
|
|
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
(urls) => {
|
|
|
|
|
setProductSetResultImages(urls);
|
|
|
|
|
const validUrls = urls.filter(Boolean);
|
|
|
|
|
const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` }));
|
|
|
|
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
|
|
|
|
...turn,
|
|
|
|
|
status: validUrls.length ? "done" : "failed",
|
|
|
|
|
errorMessage: validUrls.length ? undefined : "生成未返回结果",
|
|
|
|
|
setResultImages: validUrls,
|
|
|
|
|
results: resultCards,
|
|
|
|
|
}));
|
|
|
|
|
if (validUrls.length) {
|
|
|
|
|
setCanvasNodes((prev) => [...prev, {
|
|
|
|
|
id: `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
upsertCanvasNode({
|
|
|
|
|
id: pendingTurnId,
|
|
|
|
|
mode: "set",
|
|
|
|
|
sourceImage: productImages[0]?.src,
|
|
|
|
|
results: validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` })),
|
|
|
|
|
results: resultCards,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
x: prev.length * 420,
|
|
|
|
|
y: 0,
|
|
|
|
|
}]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
lastFailedActionRef.current = () => handleGenerate();
|
|
|
|
|
} else {
|
|
|
|
|
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
|
|
|
|
|
cloneOutput === "model"
|
|
|
|
@@ -4157,19 +4291,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
cloneOutput, productImages, requirement,
|
|
|
|
|
platform, ratio, language, market,
|
|
|
|
|
clonePromptOptions,
|
|
|
|
|
(s: string) => setStatus(s as ProductCloneStatus),
|
|
|
|
|
(s: string) => {
|
|
|
|
|
setStatus(s as ProductCloneStatus);
|
|
|
|
|
if (s === "generating") {
|
|
|
|
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined }));
|
|
|
|
|
} else if (s === "failed") {
|
|
|
|
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
(newResults: CloneResult[]) => {
|
|
|
|
|
setResults(newResults);
|
|
|
|
|
if (newResults.length && newResults[0].src) {
|
|
|
|
|
setCanvasNodes((prev) => [...prev, {
|
|
|
|
|
id: `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
const validResults = newResults.filter((item) => item.src);
|
|
|
|
|
setResults(validResults);
|
|
|
|
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
|
|
|
|
...turn,
|
|
|
|
|
status: validResults.length ? "done" : "failed",
|
|
|
|
|
errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果",
|
|
|
|
|
results: validResults,
|
|
|
|
|
setResultImages: [],
|
|
|
|
|
}));
|
|
|
|
|
if (validResults.length && validResults[0].src) {
|
|
|
|
|
upsertCanvasNode({
|
|
|
|
|
id: pendingTurnId,
|
|
|
|
|
mode: cloneOutput,
|
|
|
|
|
sourceImage: productImages[0]?.src,
|
|
|
|
|
results: newResults,
|
|
|
|
|
results: validResults,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
x: prev.length * 420,
|
|
|
|
|
y: 0,
|
|
|
|
|
}]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
@@ -4273,6 +4420,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
toast.success("已从当前视图移除");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const upsertCanvasNode = (node: Omit<CanvasNode, "x" | "y">) => {
|
|
|
|
|
setCanvasNodes((current) => {
|
|
|
|
|
const existingIndex = current.findIndex((item) => item.id === node.id);
|
|
|
|
|
if (existingIndex >= 0) {
|
|
|
|
|
return current.map((item) => (item.id === node.id ? { ...item, ...node } : item));
|
|
|
|
|
}
|
|
|
|
|
return [
|
|
|
|
|
...current,
|
|
|
|
|
{
|
|
|
|
|
...node,
|
|
|
|
|
x: current.length * 420,
|
|
|
|
|
y: current.length % 2 === 0 ? 0 : 160,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeSelectedProductSetPreview = (preview: ProductSetPreviewSelection) => {
|
|
|
|
|
if (!preview.nodeId || !preview.cardId) return;
|
|
|
|
|
removeCanvasResult(preview.nodeId, preview.cardId);
|
|
|
|
@@ -4583,46 +4747,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
sourceImages.map((item) => item.src).join("|"),
|
|
|
|
|
].join("::");
|
|
|
|
|
|
|
|
|
|
const formatHistoryTime = (timestamp: number) => {
|
|
|
|
|
const diff = Math.max(0, Date.now() - timestamp);
|
|
|
|
|
const minute = 60 * 1000;
|
|
|
|
|
const hour = 60 * minute;
|
|
|
|
|
const day = 24 * hour;
|
|
|
|
|
if (diff < minute) return "刚刚";
|
|
|
|
|
if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前";
|
|
|
|
|
if (diff < day) return String(Math.floor(diff / hour)) + " 小时前";
|
|
|
|
|
return String(Math.floor(diff / day)) + " 天前";
|
|
|
|
|
const getHistoryRecordResults = (record: EcommerceHistoryRecord) => {
|
|
|
|
|
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
|
|
|
|
|
return turns.flatMap(getTurnResults);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveCurrentEcommerceHistory = () => {
|
|
|
|
|
const historyResults = getCurrentHistoryResults();
|
|
|
|
|
if (!historyResults.length) return null;
|
|
|
|
|
const signature = buildHistorySignature(cloneOutput, requirement, historyResults, productImages);
|
|
|
|
|
if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId;
|
|
|
|
|
|
|
|
|
|
const createdAt = Date.now();
|
|
|
|
|
const outputLabel = cloneOutputOptions.find((option) => option.key === cloneOutput)?.label || "生成记录";
|
|
|
|
|
const title = requirement.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
const record: EcommerceHistoryRecord = {
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
title,
|
|
|
|
|
createdAt,
|
|
|
|
|
output: cloneOutput,
|
|
|
|
|
platform,
|
|
|
|
|
market,
|
|
|
|
|
language,
|
|
|
|
|
ratio,
|
|
|
|
|
requirement,
|
|
|
|
|
productImages,
|
|
|
|
|
results: historyResults,
|
|
|
|
|
setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [],
|
|
|
|
|
setCounts: cloneSetCounts,
|
|
|
|
|
detailModules: selectedCloneDetailModules,
|
|
|
|
|
modelScenes: selectedCloneModelScenes,
|
|
|
|
|
referenceImages: cloneReferenceImages,
|
|
|
|
|
replicateLevel: cloneReplicateLevel,
|
|
|
|
|
};
|
|
|
|
|
lastSavedHistorySignatureRef.current = signature;
|
|
|
|
|
const persistEcommerceHistoryRecord = (record: EcommerceHistoryRecord, historyResults: CloneResult[]) => {
|
|
|
|
|
void saveUnifiedEcommerceGenerationRecord({
|
|
|
|
|
clientRecordId: record.id,
|
|
|
|
|
title: record.title,
|
|
|
|
@@ -4651,9 +4781,172 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
metadata: {
|
|
|
|
|
localHistoryStorageKey: ecommerceHistoryStorageKey,
|
|
|
|
|
referenceImageCount: record.referenceImages.length,
|
|
|
|
|
turnCount: record.turns?.length ?? 1,
|
|
|
|
|
latestTurnId: record.turns?.[record.turns.length - 1]?.id,
|
|
|
|
|
},
|
|
|
|
|
createdAt: new Date(record.createdAt).toISOString(),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatHistoryTime = (timestamp: number) => {
|
|
|
|
|
const diff = Math.max(0, Date.now() - timestamp);
|
|
|
|
|
const minute = 60 * 1000;
|
|
|
|
|
const hour = 60 * minute;
|
|
|
|
|
const day = 24 * hour;
|
|
|
|
|
if (diff < minute) return "刚刚";
|
|
|
|
|
if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前";
|
|
|
|
|
if (diff < day) return String(Math.floor(diff / hour)) + " 小时前";
|
|
|
|
|
return String(Math.floor(diff / day)) + " 天前";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildEcommerceHistoryTitle = (output: CloneOutputKey, prompt: string, createdAt: number) => {
|
|
|
|
|
const outputLabel = cloneOutputOptions.find((option) => option.key === output)?.label || "生成记录";
|
|
|
|
|
return prompt.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateLocalEcommerceHistoryRecord = (recordId: string, updater: (record: EcommerceHistoryRecord) => EcommerceHistoryRecord) => {
|
|
|
|
|
setEcommerceHistoryRecords((current) => {
|
|
|
|
|
const nextRecords = current.map((record) => (record.id === recordId ? normalizeEcommerceHistoryRecord(updater(record)) : record));
|
|
|
|
|
writeEcommerceHistoryRecords(nextRecords);
|
|
|
|
|
return nextRecords;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildCurrentEcommerceHistoryTurn = (turnId: string, createdAt: number, turnStatus: EcommerceHistoryStatus = "generating"): EcommerceHistoryTurn => ({
|
|
|
|
|
id: turnId,
|
|
|
|
|
createdAt,
|
|
|
|
|
status: turnStatus,
|
|
|
|
|
output: cloneOutput,
|
|
|
|
|
platform,
|
|
|
|
|
market,
|
|
|
|
|
language,
|
|
|
|
|
ratio,
|
|
|
|
|
requirement,
|
|
|
|
|
productImages,
|
|
|
|
|
results: [],
|
|
|
|
|
setResultImages: [],
|
|
|
|
|
setCounts: cloneSetCounts,
|
|
|
|
|
detailModules: selectedCloneDetailModules,
|
|
|
|
|
modelScenes: selectedCloneModelScenes,
|
|
|
|
|
referenceImages: cloneReferenceImages,
|
|
|
|
|
replicateLevel: cloneReplicateLevel,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const syncRecordSummaryWithTurn = (record: EcommerceHistoryRecord, turn: EcommerceHistoryTurn): EcommerceHistoryRecord => ({
|
|
|
|
|
...record,
|
|
|
|
|
status: turn.status,
|
|
|
|
|
errorMessage: turn.status === "failed" ? turn.errorMessage : undefined,
|
|
|
|
|
output: turn.output,
|
|
|
|
|
platform: turn.platform,
|
|
|
|
|
market: turn.market,
|
|
|
|
|
language: turn.language,
|
|
|
|
|
ratio: turn.ratio,
|
|
|
|
|
requirement: turn.requirement,
|
|
|
|
|
productImages: turn.productImages,
|
|
|
|
|
results: turn.results,
|
|
|
|
|
setResultImages: turn.setResultImages,
|
|
|
|
|
setCounts: turn.setCounts,
|
|
|
|
|
detailModules: turn.detailModules,
|
|
|
|
|
modelScenes: turn.modelScenes,
|
|
|
|
|
referenceImages: turn.referenceImages,
|
|
|
|
|
replicateLevel: turn.replicateLevel,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateLocalEcommerceHistoryTurn = (
|
|
|
|
|
recordId: string,
|
|
|
|
|
turnId: string,
|
|
|
|
|
updater: (turn: EcommerceHistoryTurn) => EcommerceHistoryTurn,
|
|
|
|
|
) => {
|
|
|
|
|
updateLocalEcommerceHistoryRecord(recordId, (record) => {
|
|
|
|
|
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
|
|
|
|
|
let updatedTurn: EcommerceHistoryTurn | null = null;
|
|
|
|
|
const nextTurns = turns.map((turn) => {
|
|
|
|
|
if (turn.id !== turnId) return turn;
|
|
|
|
|
updatedTurn = normalizeEcommerceHistoryTurn(updater(turn), record, turns.indexOf(turn));
|
|
|
|
|
return updatedTurn;
|
|
|
|
|
});
|
|
|
|
|
return updatedTurn ? syncRecordSummaryWithTurn({ ...record, turns: nextTurns }, updatedTurn) : record;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const beginEcommerceHistoryTurn = () => {
|
|
|
|
|
const createdAt = Date.now();
|
|
|
|
|
const turn = buildCurrentEcommerceHistoryTurn(crypto.randomUUID(), createdAt);
|
|
|
|
|
const existingRecord = activeHistoryRecordId
|
|
|
|
|
? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId)
|
|
|
|
|
: null;
|
|
|
|
|
const recordId = existingRecord?.id ?? crypto.randomUUID();
|
|
|
|
|
const baseRecord: EcommerceHistoryRecord = existingRecord ?? {
|
|
|
|
|
id: recordId,
|
|
|
|
|
title: buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt),
|
|
|
|
|
createdAt,
|
|
|
|
|
status: turn.status,
|
|
|
|
|
output: turn.output,
|
|
|
|
|
platform: turn.platform,
|
|
|
|
|
market: turn.market,
|
|
|
|
|
language: turn.language,
|
|
|
|
|
ratio: turn.ratio,
|
|
|
|
|
requirement: turn.requirement,
|
|
|
|
|
productImages: turn.productImages,
|
|
|
|
|
results: turn.results,
|
|
|
|
|
setResultImages: turn.setResultImages,
|
|
|
|
|
setCounts: turn.setCounts,
|
|
|
|
|
detailModules: turn.detailModules,
|
|
|
|
|
modelScenes: turn.modelScenes,
|
|
|
|
|
referenceImages: turn.referenceImages,
|
|
|
|
|
replicateLevel: turn.replicateLevel,
|
|
|
|
|
turns: [],
|
|
|
|
|
};
|
|
|
|
|
const previousTurns = baseRecord.turns?.length ? baseRecord.turns : existingRecord ? [buildHistoryTurnFromRecord(baseRecord)] : [];
|
|
|
|
|
const record = normalizeEcommerceHistoryRecord(syncRecordSummaryWithTurn({
|
|
|
|
|
...baseRecord,
|
|
|
|
|
turns: [...previousTurns, turn],
|
|
|
|
|
}, turn));
|
|
|
|
|
setEcommerceHistoryRecords((current) => {
|
|
|
|
|
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
|
|
|
|
|
writeEcommerceHistoryRecords(nextRecords);
|
|
|
|
|
return nextRecords;
|
|
|
|
|
});
|
|
|
|
|
setActiveHistoryRecordId(record.id);
|
|
|
|
|
activeHistoryTurnIdRef.current = turn.id;
|
|
|
|
|
return { record, turn };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveCurrentEcommerceHistory = () => {
|
|
|
|
|
const activeRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
|
|
|
|
|
const historyResults = activeRecord?.turns?.length ? getHistoryRecordResults(activeRecord) : getCurrentHistoryResults();
|
|
|
|
|
if (!historyResults.length) return null;
|
|
|
|
|
const signature = activeRecord?.turns?.length
|
|
|
|
|
? buildHistorySignature(activeRecord.output, activeRecord.requirement, historyResults, activeRecord.productImages)
|
|
|
|
|
: buildHistorySignature(cloneOutput, requirement, historyResults, productImages);
|
|
|
|
|
if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId;
|
|
|
|
|
|
|
|
|
|
const createdAt = Date.now();
|
|
|
|
|
const record: EcommerceHistoryRecord = activeRecord?.turns?.length
|
|
|
|
|
? normalizeEcommerceHistoryRecord(activeRecord)
|
|
|
|
|
: normalizeEcommerceHistoryRecord({
|
|
|
|
|
id: activeRecord?.id ?? crypto.randomUUID(),
|
|
|
|
|
title: activeRecord?.title ?? buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt),
|
|
|
|
|
createdAt: activeRecord?.createdAt ?? createdAt,
|
|
|
|
|
status: "done",
|
|
|
|
|
errorMessage: undefined,
|
|
|
|
|
output: cloneOutput,
|
|
|
|
|
platform,
|
|
|
|
|
market,
|
|
|
|
|
language,
|
|
|
|
|
ratio,
|
|
|
|
|
requirement,
|
|
|
|
|
productImages,
|
|
|
|
|
results: historyResults,
|
|
|
|
|
setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [],
|
|
|
|
|
setCounts: cloneSetCounts,
|
|
|
|
|
detailModules: selectedCloneDetailModules,
|
|
|
|
|
modelScenes: selectedCloneModelScenes,
|
|
|
|
|
referenceImages: cloneReferenceImages,
|
|
|
|
|
replicateLevel: cloneReplicateLevel,
|
|
|
|
|
});
|
|
|
|
|
lastSavedHistorySignatureRef.current = signature;
|
|
|
|
|
persistEcommerceHistoryRecord(record, historyResults);
|
|
|
|
|
setEcommerceHistoryRecords((current) => {
|
|
|
|
|
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
|
|
|
|
|
writeEcommerceHistoryRecords(nextRecords);
|
|
|
|
@@ -4679,32 +4972,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
setCloneReplicateLevel(record.replicateLevel);
|
|
|
|
|
setProductSetResultImages(record.setResultImages);
|
|
|
|
|
setResults(record.output === "set" ? [] : record.results);
|
|
|
|
|
setStatus("done");
|
|
|
|
|
setStatus((record.status ?? "done") as ProductCloneStatus);
|
|
|
|
|
setPreviewZoom(1);
|
|
|
|
|
setComposerMenu(null);
|
|
|
|
|
setActiveHistoryRecordId(record.id);
|
|
|
|
|
lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, record.results, record.productImages);
|
|
|
|
|
activeHistoryTurnIdRef.current = record.status === "generating"
|
|
|
|
|
? record.turns?.find((turn) => turn.status === "generating")?.id ?? null
|
|
|
|
|
: null;
|
|
|
|
|
const recordResults = getHistoryRecordResults(record);
|
|
|
|
|
lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, recordResults, record.productImages);
|
|
|
|
|
setIsCommandComposerCompact(true);
|
|
|
|
|
|
|
|
|
|
const hasResults = record.output === "set"
|
|
|
|
|
? record.setResultImages.some(Boolean)
|
|
|
|
|
: record.results.some((r) => r.src);
|
|
|
|
|
if (hasResults) {
|
|
|
|
|
const nodeResults: CloneResult[] = record.output === "set"
|
|
|
|
|
? record.setResultImages.filter(Boolean).map((url, i) => ({ id: `set-${i}`, src: url, label: `套图 ${i + 1}` }))
|
|
|
|
|
: record.results;
|
|
|
|
|
setCanvasNodes([{
|
|
|
|
|
id: record.id,
|
|
|
|
|
mode: record.output,
|
|
|
|
|
sourceImage: record.productImages[0]?.src,
|
|
|
|
|
results: nodeResults,
|
|
|
|
|
createdAt: record.createdAt,
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
}]);
|
|
|
|
|
} else {
|
|
|
|
|
setCanvasNodes([]);
|
|
|
|
|
}
|
|
|
|
|
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
|
|
|
|
|
const nodes = turns.reduce<CanvasNode[]>((items, turn) => {
|
|
|
|
|
const turnResults = getTurnResults(turn);
|
|
|
|
|
if (!turnResults.length) return items;
|
|
|
|
|
const index = items.length;
|
|
|
|
|
items.push({
|
|
|
|
|
id: turn.id,
|
|
|
|
|
mode: turn.output,
|
|
|
|
|
sourceImage: turn.productImages[0]?.src,
|
|
|
|
|
results: turnResults,
|
|
|
|
|
createdAt: turn.createdAt,
|
|
|
|
|
x: index * 420,
|
|
|
|
|
y: index % 2 === 0 ? 0 : 160,
|
|
|
|
|
});
|
|
|
|
|
return items;
|
|
|
|
|
}, []);
|
|
|
|
|
setCanvasNodes(nodes);
|
|
|
|
|
|
|
|
|
|
setPreviewOffset({ x: 0, y: 0 });
|
|
|
|
|
previewOffsetRef.current = { x: 0, y: 0 };
|
|
|
|
@@ -4719,6 +5014,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
setComposerMenu(null);
|
|
|
|
|
setIsCommandComposerCompact(false);
|
|
|
|
|
setActiveHistoryRecordId(null);
|
|
|
|
|
activeHistoryTurnIdRef.current = null;
|
|
|
|
|
lastSavedHistorySignatureRef.current = "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -4757,7 +5053,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
const next = ecommerceHistoryRecords.filter((r) => r.id !== recordId);
|
|
|
|
|
setEcommerceHistoryRecords(next);
|
|
|
|
|
writeEcommerceHistoryRecords(next);
|
|
|
|
|
if (activeHistoryRecordId === recordId) setActiveHistoryRecordId(null);
|
|
|
|
|
if (activeHistoryRecordId === recordId) {
|
|
|
|
|
setActiveHistoryRecordId(null);
|
|
|
|
|
activeHistoryTurnIdRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
deleteEcommerceGenerationRecord(recordId).catch(() => {});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -7417,26 +7716,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
|
|
|
|
|
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
|
|
|
|
|
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
|
|
|
|
|
const currentResultThumbs = canvasNodes.flatMap((node) => node.results).slice(0, 6);
|
|
|
|
|
const activeHistoryImageIds = new Set((activeHistoryRecord?.productImages ?? []).map((image) => image.id));
|
|
|
|
|
const historyConversationImages = activeHistoryRecord?.productImages?.length ? activeHistoryRecord.productImages : productImages;
|
|
|
|
|
const newConversationImages = activeHistoryRecord ? productImages.filter((image) => !activeHistoryImageIds.has(image.id)) : [];
|
|
|
|
|
const historyRequirementText = activeHistoryRecord?.requirement?.trim() || requirement.trim();
|
|
|
|
|
const newRequirementText = requirement.trim() && requirement.trim() !== historyRequirementText
|
|
|
|
|
? requirement.trim()
|
|
|
|
|
: "继续上传素材,准备下一轮生成。";
|
|
|
|
|
const historyRequirementMeta = [
|
|
|
|
|
{ label: "平台", value: activeHistoryRecord?.platform || platform },
|
|
|
|
|
{ label: "语种", value: activeHistoryRecord?.language || language },
|
|
|
|
|
{ label: "比例", value: formatRatioDisplayValue(activeHistoryRecord?.ratio || ratio) },
|
|
|
|
|
{ label: "设置", value: composerSettingLabel },
|
|
|
|
|
];
|
|
|
|
|
const currentRequirementMeta = [
|
|
|
|
|
{ label: "平台", value: platform },
|
|
|
|
|
{ label: "语种", value: language },
|
|
|
|
|
{ label: "比例", value: formatRatioDisplayValue(ratio) },
|
|
|
|
|
{ label: "设置", value: composerSettingLabel },
|
|
|
|
|
];
|
|
|
|
|
const activeConversationTurns = activeHistoryRecord
|
|
|
|
|
? activeHistoryRecord.turns?.length
|
|
|
|
|
? activeHistoryRecord.turns
|
|
|
|
|
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
|
|
|
|
|
: [];
|
|
|
|
|
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
|
|
|
|
|
if (turn.output === "set") {
|
|
|
|
|
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
|
|
|
|
|
return `套图 ${total || 1}张`;
|
|
|
|
|
}
|
|
|
|
|
if (turn.output === "detail") return `详情 ${turn.detailModules?.length || 1}项`;
|
|
|
|
|
if (turn.output === "model") return `模特 ${turn.modelScenes?.length || 1}景`;
|
|
|
|
|
return cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
|
|
|
|
|
};
|
|
|
|
|
const restoreHistoryTurnInputs = (turn: EcommerceHistoryTurn) => {
|
|
|
|
|
setCloneOutput(turn.output);
|
|
|
|
|
setPlatform(turn.platform);
|
|
|
|
|
setMarket(turn.market);
|
|
|
|
|
setLanguage(turn.language);
|
|
|
|
|
setRatio(turn.ratio);
|
|
|
|
|
setRequirement(turn.requirement);
|
|
|
|
|
setProductImages(turn.productImages);
|
|
|
|
|
setCloneSetCounts(turn.setCounts);
|
|
|
|
|
setSelectedCloneDetailModules(turn.detailModules.slice(0, maxDetailModuleSelection));
|
|
|
|
|
setSelectedCloneModelScenes(turn.modelScenes);
|
|
|
|
|
setCloneReferenceImages(turn.referenceImages);
|
|
|
|
|
setCloneReplicateLevel(turn.replicateLevel);
|
|
|
|
|
toast.info("已恢复该轮参数,可继续发送");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
@@ -7496,75 +7804,73 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
</button>
|
|
|
|
|
</header>
|
|
|
|
|
<div className="clone-ai-conversation-body">
|
|
|
|
|
<section className="clone-ai-chat-message clone-ai-chat-message--user">
|
|
|
|
|
<span>需求</span>
|
|
|
|
|
<p>{historyRequirementText || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
|
|
|
|
|
<div className="clone-ai-chat-meta" aria-label="需求参数">
|
|
|
|
|
{historyRequirementMeta.map((item) => (
|
|
|
|
|
<em key={item.label}>
|
|
|
|
|
<span>{item.label}</span>
|
|
|
|
|
<strong>{item.value}</strong>
|
|
|
|
|
</em>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{historyConversationImages.length ? (
|
|
|
|
|
<div className="clone-ai-chat-assets" aria-label="已上传素材">
|
|
|
|
|
{historyConversationImages.slice(0, 4).map((image) => (
|
|
|
|
|
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
|
|
|
|
|
))}
|
|
|
|
|
{historyConversationImages.length > 4 ? <em>+{historyConversationImages.length - 4}</em> : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${status}`}>
|
|
|
|
|
<span>电商图设计师</span>
|
|
|
|
|
<p>
|
|
|
|
|
{status === "done" || currentResultCount > 0
|
|
|
|
|
? `已生成 ${currentResultCount || results.length || productSetResultImages.filter(Boolean).length} 张结果,可在画布中拖拽、缩放和预览。`
|
|
|
|
|
: status === "generating"
|
|
|
|
|
? `正在为 ${platform} / ${market} 生成${selectedCloneOutput.label},结果会自动出现在中间画布。`
|
|
|
|
|
: status === "failed"
|
|
|
|
|
? "生成失败,请检查网络或参数后重试。"
|
|
|
|
|
: "我会根据商品图、平台规则和提示词整理生成任务。"}
|
|
|
|
|
</p>
|
|
|
|
|
{status === "generating" ? (
|
|
|
|
|
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
|
|
|
|
|
) : null}
|
|
|
|
|
{currentResultThumbs.length ? (
|
|
|
|
|
<div className="clone-ai-chat-results" aria-label="生成结果缩略图">
|
|
|
|
|
{currentResultThumbs.map((item) => (
|
|
|
|
|
<button
|
|
|
|
|
key={item.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => openProductSetPreview(item)}
|
|
|
|
|
aria-label={`预览${item.label}`}
|
|
|
|
|
>
|
|
|
|
|
<img src={item.src} alt={item.label} />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
{newConversationImages.length ? (
|
|
|
|
|
<section className="clone-ai-chat-message clone-ai-chat-message--user clone-ai-chat-message--followup">
|
|
|
|
|
<span>新需求</span>
|
|
|
|
|
<p>{newRequirementText}</p>
|
|
|
|
|
<div className="clone-ai-chat-meta" aria-label="新需求参数">
|
|
|
|
|
{currentRequirementMeta.map((item) => (
|
|
|
|
|
<em key={item.label}>
|
|
|
|
|
<span>{item.label}</span>
|
|
|
|
|
<strong>{item.value}</strong>
|
|
|
|
|
</em>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="clone-ai-chat-assets" aria-label="新增素材">
|
|
|
|
|
{newConversationImages.slice(0, 4).map((image) => (
|
|
|
|
|
<img key={image.id} src={image.src} alt={image.name || "新增商品素材"} />
|
|
|
|
|
))}
|
|
|
|
|
{newConversationImages.length > 4 ? <em>+{newConversationImages.length - 4}</em> : null}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
{activeConversationTurns.map((turn, index) => {
|
|
|
|
|
const turnResults = getTurnResults(turn);
|
|
|
|
|
const outputLabel = cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
|
|
|
|
|
const turnMeta = [
|
|
|
|
|
{ label: "平台", value: turn.platform },
|
|
|
|
|
{ label: "语种", value: turn.language },
|
|
|
|
|
{ label: "比例", value: formatRatioDisplayValue(turn.ratio) },
|
|
|
|
|
{ label: "设置", value: getHistoryTurnSettingLabel(turn) },
|
|
|
|
|
];
|
|
|
|
|
const isCurrentGeneratingTurn = turn.status === "generating" && turn.id === activeHistoryTurnIdRef.current;
|
|
|
|
|
return (
|
|
|
|
|
<Fragment key={turn.id}>
|
|
|
|
|
<section className={`clone-ai-chat-message clone-ai-chat-message--user${index > 0 ? " clone-ai-chat-message--followup" : ""}`}>
|
|
|
|
|
<span>{index === 0 ? "需求" : `继续生成 ${index + 1}`}</span>
|
|
|
|
|
<p>{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
|
|
|
|
|
<div className="clone-ai-chat-meta" aria-label="需求参数">
|
|
|
|
|
{turnMeta.map((item) => (
|
|
|
|
|
<em key={item.label}>
|
|
|
|
|
<span>{item.label}</span>
|
|
|
|
|
<strong>{item.value}</strong>
|
|
|
|
|
</em>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{turn.productImages.length ? (
|
|
|
|
|
<div className="clone-ai-chat-assets" aria-label="已上传素材">
|
|
|
|
|
{turn.productImages.slice(0, 4).map((image) => (
|
|
|
|
|
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
|
|
|
|
|
))}
|
|
|
|
|
{turn.productImages.length > 4 ? <em>+{turn.productImages.length - 4}</em> : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${turn.status}`}>
|
|
|
|
|
<span>电商图设计师</span>
|
|
|
|
|
<p>
|
|
|
|
|
{turn.status === "done" || turnResults.length
|
|
|
|
|
? `已生成 ${turnResults.length} 张${outputLabel},已同步到中间画布,可拖拽、缩放和预览。`
|
|
|
|
|
: turn.status === "generating"
|
|
|
|
|
? `正在为 ${turn.platform} / ${turn.market} 生成${outputLabel},完成后会自动追加到画布。`
|
|
|
|
|
: turn.errorMessage || "生成失败,请检查网络或参数后重试。"}
|
|
|
|
|
</p>
|
|
|
|
|
{isCurrentGeneratingTurn ? (
|
|
|
|
|
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${outputLabel}生成`} />
|
|
|
|
|
) : null}
|
|
|
|
|
{turn.status === "failed" ? (
|
|
|
|
|
<button type="button" className="clone-ai-retry-btn" onClick={() => restoreHistoryTurnInputs(turn)}>
|
|
|
|
|
<ReloadOutlined /> 恢复参数
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
{turnResults.length ? (
|
|
|
|
|
<div className="clone-ai-chat-results" aria-label="生成结果缩略图">
|
|
|
|
|
{turnResults.slice(0, 6).map((item) => (
|
|
|
|
|
<button
|
|
|
|
|
key={item.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => openProductSetPreview(item)}
|
|
|
|
|
aria-label={`预览${item.label}`}
|
|
|
|
|
>
|
|
|
|
|
<img src={item.src} alt={item.label} />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
</Fragment>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
<button
|
|
|
|
@@ -7628,6 +7934,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
{ecommerceHistoryRecords.length ? (
|
|
|
|
|
ecommerceHistoryRecords.map((record) => {
|
|
|
|
|
const outputLabel = cloneOutputOptions.find((option) => option.key === record.output)?.label || "生成记录";
|
|
|
|
|
const statusLabel = record.status === "generating" ? "生成中" : record.status === "failed" ? "失败" : formatHistoryTime(record.createdAt);
|
|
|
|
|
return (
|
|
|
|
|
<div key={`${record.id}-${historyRefreshTick}`} className={`ecom-command-history__item${activeHistoryRecordId === record.id ? " is-active" : ""}`}>
|
|
|
|
|
<button
|
|
|
|
@@ -7636,7 +7943,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
onClick={() => openEcommerceHistoryRecord(record)}
|
|
|
|
|
>
|
|
|
|
|
<strong>{record.title}</strong>
|
|
|
|
|
<span>{outputLabel} · {formatHistoryTime(record.createdAt)}</span>
|
|
|
|
|
<span>{outputLabel} · {statusLabel}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
@@ -7656,7 +7963,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
</nav>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{selectedProductSetPreview ? (
|
|
|
|
|
{selectedProductSetPreview && typeof document !== "undefined" ? createPortal((
|
|
|
|
|
<div className="product-set-preview-backdrop" role="presentation" onClick={() => setSelectedProductSetPreview(null)}>
|
|
|
|
|
<section
|
|
|
|
|
className="product-set-preview-modal"
|
|
|
|
@@ -7701,7 +8008,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
), document.body) : null}
|
|
|
|
|
|
|
|
|
|
{showHostingModal ? (
|
|
|
|
|
<div className="product-set-hosting-backdrop" role="presentation">
|
|
|
|
|