Merge pull request 'feat: implement multi-turn conversation system for generation record detail with deduplication enhancement' (#18) from feat/ecommerce-record-detail-conversation-panel into main
Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
@@ -43,15 +43,40 @@ export interface SaveGenerationRecordResult {
|
|||||||
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
||||||
// 避免后端在缺少去重时插入重复记录。
|
// 避免后端在缺少去重时插入重复记录。
|
||||||
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
||||||
const recentlySavedAt = new Map<string, number>();
|
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
|
||||||
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
function pruneRecentlySaved(now: number): void {
|
function pruneRecentlySaved(now: number): void {
|
||||||
for (const [id, savedAt] of recentlySavedAt) {
|
for (const [id, record] of recentlySavedRecords) {
|
||||||
if (now - savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedAt.delete(id);
|
if (now - record.savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedRecords.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stableJsonStringify(value: unknown): string {
|
||||||
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableJsonStringify).join(",")}]`;
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
.filter(([, entryValue]) => entryValue !== undefined)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSaveSignature(input: SaveGenerationRecordInput): string {
|
||||||
|
return stableJsonStringify({
|
||||||
|
tool: input.tool,
|
||||||
|
mode: input.mode,
|
||||||
|
title: input.title,
|
||||||
|
status: input.status,
|
||||||
|
prompt: input.prompt,
|
||||||
|
taskIds: input.taskIds,
|
||||||
|
assets: input.assets,
|
||||||
|
config: input.config,
|
||||||
|
result: input.result,
|
||||||
|
metadata: input.metadata,
|
||||||
|
createdAt: input.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readPendingRecords(): SaveGenerationRecordInput[] {
|
function readPendingRecords(): SaveGenerationRecordInput[] {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
||||||
@@ -78,26 +103,29 @@ export async function saveGenerationRecord(input: SaveGenerationRecordInput): Pr
|
|||||||
pruneRecentlySaved(now);
|
pruneRecentlySaved(now);
|
||||||
|
|
||||||
const recordId = input.clientRecordId;
|
const recordId = input.clientRecordId;
|
||||||
|
const signature = buildSaveSignature(input);
|
||||||
if (recordId) {
|
if (recordId) {
|
||||||
const inFlight = inFlightSaves.get(recordId);
|
const saveKey = `${recordId}:${signature}`;
|
||||||
|
const inFlight = inFlightSaves.get(saveKey);
|
||||||
if (inFlight) return inFlight;
|
if (inFlight) return inFlight;
|
||||||
const savedAt = recentlySavedAt.get(recordId);
|
const savedRecord = recentlySavedRecords.get(recordId);
|
||||||
if (savedAt !== undefined && now - savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
||||||
// 终态记录只需落库一次;窗口内的重复调用直接视为已保存。
|
// 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。
|
||||||
return { source: "server", id: recordId };
|
return { source: "server", id: recordId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = saveGenerationRecordInternal(input);
|
const promise = saveGenerationRecordInternal(input);
|
||||||
if (recordId) {
|
if (recordId) {
|
||||||
inFlightSaves.set(recordId, promise);
|
const saveKey = `${recordId}:${signature}`;
|
||||||
|
inFlightSaves.set(saveKey, promise);
|
||||||
void promise
|
void promise
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.source === "server") recentlySavedAt.set(recordId, Date.now());
|
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inFlightSaves.delete(recordId);
|
inFlightSaves.delete(saveKey);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return promise;
|
return promise;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
TableOutlined,
|
TableOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from "@ant-design/icons";
|
} 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 { createPortal } from "react-dom";
|
||||||
import { useTypewriter } from "../../hooks/useTypewriter";
|
import { useTypewriter } from "../../hooks/useTypewriter";
|
||||||
import "../../styles/pages/ecommerce.css";
|
import "../../styles/pages/ecommerce.css";
|
||||||
@@ -363,10 +363,13 @@ interface CloneSavedSetting {
|
|||||||
requirement: string;
|
requirement: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EcommerceHistoryRecord {
|
type EcommerceHistoryStatus = "generating" | "done" | "failed";
|
||||||
|
|
||||||
|
interface EcommerceHistoryTurn {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
status: EcommerceHistoryStatus;
|
||||||
|
errorMessage?: string;
|
||||||
output: CloneOutputKey;
|
output: CloneOutputKey;
|
||||||
platform: string;
|
platform: string;
|
||||||
market: string;
|
market: string;
|
||||||
@@ -383,6 +386,29 @@ interface EcommerceHistoryRecord {
|
|||||||
replicateLevel: CloneReplicateLevelKey;
|
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 {
|
interface ProductSetPreviewSelection {
|
||||||
src: string;
|
src: string;
|
||||||
label: 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 {
|
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,
|
...record,
|
||||||
|
status,
|
||||||
|
errorMessage: status === "failed" ? record.errorMessage : undefined,
|
||||||
productImages: removeFilePayloadFromImages(record.productImages),
|
productImages: removeFilePayloadFromImages(record.productImages),
|
||||||
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
||||||
results: record.results ?? [],
|
results: record.results ?? [],
|
||||||
@@ -1445,6 +1529,14 @@ function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): Ecomme
|
|||||||
modelScenes: record.modelScenes ?? [],
|
modelScenes: record.modelScenes ?? [],
|
||||||
replicateLevel: record.replicateLevel ?? "high",
|
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() {
|
function readEcommerceHistoryRecords() {
|
||||||
@@ -1982,6 +2074,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const historyRefreshLockRef = useRef(false);
|
const historyRefreshLockRef = useRef(false);
|
||||||
const lastSavedHistorySignatureRef = useRef("");
|
const lastSavedHistorySignatureRef = useRef("");
|
||||||
const imageAbortRef = useRef({ current: false });
|
const imageAbortRef = useRef({ current: false });
|
||||||
|
const activeHistoryTurnIdRef = useRef<string | null>(null);
|
||||||
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
|
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
|
||||||
const lastFailedActionRef = useRef<(() => void) | null>(null);
|
const lastFailedActionRef = useRef<(() => void) | null>(null);
|
||||||
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
|
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
|
||||||
@@ -2113,7 +2206,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
});
|
});
|
||||||
lastFailedActionRef.current = null;
|
lastFailedActionRef.current = null;
|
||||||
if (productSetStatus === "generating") setProductSetStatus("idle");
|
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 (detailStatus === "generating") setDetailStatus("idle");
|
||||||
if (tryOnStatus === "generating") setTryOnStatus("idle");
|
if (tryOnStatus === "generating") setTryOnStatus("idle");
|
||||||
if (tryOnStatus === "modeling") setTryOnStatus("ready");
|
if (tryOnStatus === "modeling") setTryOnStatus("ready");
|
||||||
@@ -4118,27 +4229,50 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
setIsCommandComposerCompact(true);
|
setIsCommandComposerCompact(true);
|
||||||
imageAbortRef.current = { current: false };
|
imageAbortRef.current = { current: false };
|
||||||
lastFailedActionRef.current = null;
|
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") {
|
if (cloneOutput === "set") {
|
||||||
void generateSetImages(
|
void generateSetImages(
|
||||||
productImages, cloneSetCounts, requirement,
|
productImages, cloneSetCounts, requirement,
|
||||||
platform, ratio, language, market,
|
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) => {
|
(urls) => {
|
||||||
setProductSetResultImages(urls);
|
setProductSetResultImages(urls);
|
||||||
const validUrls = urls.filter(Boolean);
|
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) {
|
if (validUrls.length) {
|
||||||
setCanvasNodes((prev) => [...prev, {
|
upsertCanvasNode({
|
||||||
id: `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: pendingTurnId,
|
||||||
mode: "set",
|
mode: "set",
|
||||||
sourceImage: productImages[0]?.src,
|
sourceImage: productImages[0]?.src,
|
||||||
results: validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` })),
|
results: resultCards,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
x: prev.length * 420,
|
});
|
||||||
y: 0,
|
|
||||||
}]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
lastFailedActionRef.current = () => handleGenerate();
|
||||||
} else {
|
} else {
|
||||||
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
|
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
|
||||||
cloneOutput === "model"
|
cloneOutput === "model"
|
||||||
@@ -4157,19 +4291,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
cloneOutput, productImages, requirement,
|
cloneOutput, productImages, requirement,
|
||||||
platform, ratio, language, market,
|
platform, ratio, language, market,
|
||||||
clonePromptOptions,
|
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[]) => {
|
(newResults: CloneResult[]) => {
|
||||||
setResults(newResults);
|
const validResults = newResults.filter((item) => item.src);
|
||||||
if (newResults.length && newResults[0].src) {
|
setResults(validResults);
|
||||||
setCanvasNodes((prev) => [...prev, {
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
||||||
id: `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
...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,
|
mode: cloneOutput,
|
||||||
sourceImage: productImages[0]?.src,
|
sourceImage: productImages[0]?.src,
|
||||||
results: newResults,
|
results: validResults,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
x: prev.length * 420,
|
});
|
||||||
y: 0,
|
|
||||||
}]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -4273,6 +4420,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
toast.success("已从当前视图移除");
|
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) => {
|
const removeSelectedProductSetPreview = (preview: ProductSetPreviewSelection) => {
|
||||||
if (!preview.nodeId || !preview.cardId) return;
|
if (!preview.nodeId || !preview.cardId) return;
|
||||||
removeCanvasResult(preview.nodeId, preview.cardId);
|
removeCanvasResult(preview.nodeId, preview.cardId);
|
||||||
@@ -4583,46 +4747,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
sourceImages.map((item) => item.src).join("|"),
|
sourceImages.map((item) => item.src).join("|"),
|
||||||
].join("::");
|
].join("::");
|
||||||
|
|
||||||
const formatHistoryTime = (timestamp: number) => {
|
const getHistoryRecordResults = (record: EcommerceHistoryRecord) => {
|
||||||
const diff = Math.max(0, Date.now() - timestamp);
|
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
|
||||||
const minute = 60 * 1000;
|
return turns.flatMap(getTurnResults);
|
||||||
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 saveCurrentEcommerceHistory = () => {
|
const persistEcommerceHistoryRecord = (record: EcommerceHistoryRecord, historyResults: CloneResult[]) => {
|
||||||
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;
|
|
||||||
void saveUnifiedEcommerceGenerationRecord({
|
void saveUnifiedEcommerceGenerationRecord({
|
||||||
clientRecordId: record.id,
|
clientRecordId: record.id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
@@ -4651,9 +4781,172 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
localHistoryStorageKey: ecommerceHistoryStorageKey,
|
localHistoryStorageKey: ecommerceHistoryStorageKey,
|
||||||
referenceImageCount: record.referenceImages.length,
|
referenceImageCount: record.referenceImages.length,
|
||||||
|
turnCount: record.turns?.length ?? 1,
|
||||||
|
latestTurnId: record.turns?.[record.turns.length - 1]?.id,
|
||||||
},
|
},
|
||||||
createdAt: new Date(record.createdAt).toISOString(),
|
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) => {
|
setEcommerceHistoryRecords((current) => {
|
||||||
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
|
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
|
||||||
writeEcommerceHistoryRecords(nextRecords);
|
writeEcommerceHistoryRecords(nextRecords);
|
||||||
@@ -4679,32 +4972,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
setCloneReplicateLevel(record.replicateLevel);
|
setCloneReplicateLevel(record.replicateLevel);
|
||||||
setProductSetResultImages(record.setResultImages);
|
setProductSetResultImages(record.setResultImages);
|
||||||
setResults(record.output === "set" ? [] : record.results);
|
setResults(record.output === "set" ? [] : record.results);
|
||||||
setStatus("done");
|
setStatus((record.status ?? "done") as ProductCloneStatus);
|
||||||
setPreviewZoom(1);
|
setPreviewZoom(1);
|
||||||
setComposerMenu(null);
|
setComposerMenu(null);
|
||||||
setActiveHistoryRecordId(record.id);
|
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);
|
setIsCommandComposerCompact(true);
|
||||||
|
|
||||||
const hasResults = record.output === "set"
|
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
|
||||||
? record.setResultImages.some(Boolean)
|
const nodes = turns.reduce<CanvasNode[]>((items, turn) => {
|
||||||
: record.results.some((r) => r.src);
|
const turnResults = getTurnResults(turn);
|
||||||
if (hasResults) {
|
if (!turnResults.length) return items;
|
||||||
const nodeResults: CloneResult[] = record.output === "set"
|
const index = items.length;
|
||||||
? record.setResultImages.filter(Boolean).map((url, i) => ({ id: `set-${i}`, src: url, label: `套图 ${i + 1}` }))
|
items.push({
|
||||||
: record.results;
|
id: turn.id,
|
||||||
setCanvasNodes([{
|
mode: turn.output,
|
||||||
id: record.id,
|
sourceImage: turn.productImages[0]?.src,
|
||||||
mode: record.output,
|
results: turnResults,
|
||||||
sourceImage: record.productImages[0]?.src,
|
createdAt: turn.createdAt,
|
||||||
results: nodeResults,
|
x: index * 420,
|
||||||
createdAt: record.createdAt,
|
y: index % 2 === 0 ? 0 : 160,
|
||||||
x: 0,
|
});
|
||||||
y: 0,
|
return items;
|
||||||
}]);
|
}, []);
|
||||||
} else {
|
setCanvasNodes(nodes);
|
||||||
setCanvasNodes([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviewOffset({ x: 0, y: 0 });
|
setPreviewOffset({ x: 0, y: 0 });
|
||||||
previewOffsetRef.current = { x: 0, y: 0 };
|
previewOffsetRef.current = { x: 0, y: 0 };
|
||||||
@@ -4719,6 +5014,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
setComposerMenu(null);
|
setComposerMenu(null);
|
||||||
setIsCommandComposerCompact(false);
|
setIsCommandComposerCompact(false);
|
||||||
setActiveHistoryRecordId(null);
|
setActiveHistoryRecordId(null);
|
||||||
|
activeHistoryTurnIdRef.current = null;
|
||||||
lastSavedHistorySignatureRef.current = "";
|
lastSavedHistorySignatureRef.current = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4757,7 +5053,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const next = ecommerceHistoryRecords.filter((r) => r.id !== recordId);
|
const next = ecommerceHistoryRecords.filter((r) => r.id !== recordId);
|
||||||
setEcommerceHistoryRecords(next);
|
setEcommerceHistoryRecords(next);
|
||||||
writeEcommerceHistoryRecords(next);
|
writeEcommerceHistoryRecords(next);
|
||||||
if (activeHistoryRecordId === recordId) setActiveHistoryRecordId(null);
|
if (activeHistoryRecordId === recordId) {
|
||||||
|
setActiveHistoryRecordId(null);
|
||||||
|
activeHistoryTurnIdRef.current = null;
|
||||||
|
}
|
||||||
deleteEcommerceGenerationRecord(recordId).catch(() => {});
|
deleteEcommerceGenerationRecord(recordId).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -7417,26 +7716,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
|
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
|
||||||
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
|
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
|
||||||
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
|
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
|
||||||
const currentResultThumbs = canvasNodes.flatMap((node) => node.results).slice(0, 6);
|
const activeConversationTurns = activeHistoryRecord
|
||||||
const activeHistoryImageIds = new Set((activeHistoryRecord?.productImages ?? []).map((image) => image.id));
|
? activeHistoryRecord.turns?.length
|
||||||
const historyConversationImages = activeHistoryRecord?.productImages?.length ? activeHistoryRecord.productImages : productImages;
|
? activeHistoryRecord.turns
|
||||||
const newConversationImages = activeHistoryRecord ? productImages.filter((image) => !activeHistoryImageIds.has(image.id)) : [];
|
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
|
||||||
const historyRequirementText = activeHistoryRecord?.requirement?.trim() || requirement.trim();
|
: [];
|
||||||
const newRequirementText = requirement.trim() && requirement.trim() !== historyRequirementText
|
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
|
||||||
? requirement.trim()
|
if (turn.output === "set") {
|
||||||
: "继续上传素材,准备下一轮生成。";
|
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
|
||||||
const historyRequirementMeta = [
|
return `套图 ${total || 1}张`;
|
||||||
{ label: "平台", value: activeHistoryRecord?.platform || platform },
|
}
|
||||||
{ label: "语种", value: activeHistoryRecord?.language || language },
|
if (turn.output === "detail") return `详情 ${turn.detailModules?.length || 1}项`;
|
||||||
{ label: "比例", value: formatRatioDisplayValue(activeHistoryRecord?.ratio || ratio) },
|
if (turn.output === "model") return `模特 ${turn.modelScenes?.length || 1}景`;
|
||||||
{ label: "设置", value: composerSettingLabel },
|
return cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
|
||||||
];
|
};
|
||||||
const currentRequirementMeta = [
|
const restoreHistoryTurnInputs = (turn: EcommerceHistoryTurn) => {
|
||||||
{ label: "平台", value: platform },
|
setCloneOutput(turn.output);
|
||||||
{ label: "语种", value: language },
|
setPlatform(turn.platform);
|
||||||
{ label: "比例", value: formatRatioDisplayValue(ratio) },
|
setMarket(turn.market);
|
||||||
{ label: "设置", value: composerSettingLabel },
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -7496,43 +7804,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="clone-ai-conversation-body">
|
<div className="clone-ai-conversation-body">
|
||||||
<section className="clone-ai-chat-message clone-ai-chat-message--user">
|
{activeConversationTurns.map((turn, index) => {
|
||||||
<span>需求</span>
|
const turnResults = getTurnResults(turn);
|
||||||
<p>{historyRequirementText || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
|
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="需求参数">
|
<div className="clone-ai-chat-meta" aria-label="需求参数">
|
||||||
{historyRequirementMeta.map((item) => (
|
{turnMeta.map((item) => (
|
||||||
<em key={item.label}>
|
<em key={item.label}>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<strong>{item.value}</strong>
|
<strong>{item.value}</strong>
|
||||||
</em>
|
</em>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{historyConversationImages.length ? (
|
{turn.productImages.length ? (
|
||||||
<div className="clone-ai-chat-assets" aria-label="已上传素材">
|
<div className="clone-ai-chat-assets" aria-label="已上传素材">
|
||||||
{historyConversationImages.slice(0, 4).map((image) => (
|
{turn.productImages.slice(0, 4).map((image) => (
|
||||||
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
|
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
|
||||||
))}
|
))}
|
||||||
{historyConversationImages.length > 4 ? <em>+{historyConversationImages.length - 4}</em> : null}
|
{turn.productImages.length > 4 ? <em>+{turn.productImages.length - 4}</em> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${status}`}>
|
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${turn.status}`}>
|
||||||
<span>电商图设计师</span>
|
<span>电商图设计师</span>
|
||||||
<p>
|
<p>
|
||||||
{status === "done" || currentResultCount > 0
|
{turn.status === "done" || turnResults.length
|
||||||
? `已生成 ${currentResultCount || results.length || productSetResultImages.filter(Boolean).length} 张结果,可在画布中拖拽、缩放和预览。`
|
? `已生成 ${turnResults.length} 张${outputLabel},已同步到中间画布,可拖拽、缩放和预览。`
|
||||||
: status === "generating"
|
: turn.status === "generating"
|
||||||
? `正在为 ${platform} / ${market} 生成${selectedCloneOutput.label},结果会自动出现在中间画布。`
|
? `正在为 ${turn.platform} / ${turn.market} 生成${outputLabel},完成后会自动追加到画布。`
|
||||||
: status === "failed"
|
: turn.errorMessage || "生成失败,请检查网络或参数后重试。"}
|
||||||
? "生成失败,请检查网络或参数后重试。"
|
|
||||||
: "我会根据商品图、平台规则和提示词整理生成任务。"}
|
|
||||||
</p>
|
</p>
|
||||||
{status === "generating" ? (
|
{isCurrentGeneratingTurn ? (
|
||||||
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
|
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${outputLabel}生成`} />
|
||||||
) : null}
|
) : null}
|
||||||
{currentResultThumbs.length ? (
|
{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="生成结果缩略图">
|
<div className="clone-ai-chat-results" aria-label="生成结果缩略图">
|
||||||
{currentResultThumbs.map((item) => (
|
{turnResults.slice(0, 6).map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -7545,26 +7868,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
{newConversationImages.length ? (
|
</Fragment>
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<button
|
<button
|
||||||
@@ -7628,6 +7934,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
{ecommerceHistoryRecords.length ? (
|
{ecommerceHistoryRecords.length ? (
|
||||||
ecommerceHistoryRecords.map((record) => {
|
ecommerceHistoryRecords.map((record) => {
|
||||||
const outputLabel = cloneOutputOptions.find((option) => option.key === record.output)?.label || "生成记录";
|
const outputLabel = cloneOutputOptions.find((option) => option.key === record.output)?.label || "生成记录";
|
||||||
|
const statusLabel = record.status === "generating" ? "生成中" : record.status === "failed" ? "失败" : formatHistoryTime(record.createdAt);
|
||||||
return (
|
return (
|
||||||
<div key={`${record.id}-${historyRefreshTick}`} className={`ecom-command-history__item${activeHistoryRecordId === record.id ? " is-active" : ""}`}>
|
<div key={`${record.id}-${historyRefreshTick}`} className={`ecom-command-history__item${activeHistoryRecordId === record.id ? " is-active" : ""}`}>
|
||||||
<button
|
<button
|
||||||
@@ -7636,7 +7943,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
onClick={() => openEcommerceHistoryRecord(record)}
|
onClick={() => openEcommerceHistoryRecord(record)}
|
||||||
>
|
>
|
||||||
<strong>{record.title}</strong>
|
<strong>{record.title}</strong>
|
||||||
<span>{outputLabel} · {formatHistoryTime(record.createdAt)}</span>
|
<span>{outputLabel} · {statusLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -7656,7 +7963,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{selectedProductSetPreview ? (
|
{selectedProductSetPreview && typeof document !== "undefined" ? createPortal((
|
||||||
<div className="product-set-preview-backdrop" role="presentation" onClick={() => setSelectedProductSetPreview(null)}>
|
<div className="product-set-preview-backdrop" role="presentation" onClick={() => setSelectedProductSetPreview(null)}>
|
||||||
<section
|
<section
|
||||||
className="product-set-preview-modal"
|
className="product-set-preview-modal"
|
||||||
@@ -7701,7 +8008,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
), document.body) : null}
|
||||||
|
|
||||||
{showHostingModal ? (
|
{showHostingModal ? (
|
||||||
<div className="product-set-hosting-backdrop" role="presentation">
|
<div className="product-set-hosting-backdrop" role="presentation">
|
||||||
|
|||||||
@@ -7861,7 +7861,7 @@
|
|||||||
.product-set-preview-backdrop {
|
.product-set-preview-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 100;
|
z-index: 4000;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgb(17 24 39 / 58%);
|
background: rgb(17 24 39 / 58%);
|
||||||
|
|||||||
Reference in New Issue
Block a user