diff --git a/src/api/generationRecordClient.ts b/src/api/generationRecordClient.ts index 75e7d04..9e376d9 100644 --- a/src/api/generationRecordClient.ts +++ b/src/api/generationRecordClient.ts @@ -43,15 +43,40 @@ export interface SaveGenerationRecordResult { // 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截, // 避免后端在缺少去重时插入重复记录。 const inFlightSaves = new Map>(); -const recentlySavedAt = new Map(); +const recentlySavedRecords = new Map(); const SAVE_DEDUPE_WINDOW_MS = 60_000; function pruneRecentlySaved(now: number): void { - for (const [id, savedAt] of recentlySavedAt) { - if (now - savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedAt.delete(id); + for (const [id, record] of recentlySavedRecords) { + 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) + .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[] { try { const raw = window.localStorage.getItem(PENDING_RECORDS_KEY); @@ -78,26 +103,29 @@ export async function saveGenerationRecord(input: SaveGenerationRecordInput): Pr pruneRecentlySaved(now); const recordId = input.clientRecordId; + const signature = buildSaveSignature(input); if (recordId) { - const inFlight = inFlightSaves.get(recordId); + const saveKey = `${recordId}:${signature}`; + const inFlight = inFlightSaves.get(saveKey); if (inFlight) return inFlight; - const savedAt = recentlySavedAt.get(recordId); - if (savedAt !== undefined && now - savedAt <= SAVE_DEDUPE_WINDOW_MS) { - // 终态记录只需落库一次;窗口内的重复调用直接视为已保存。 + const savedRecord = recentlySavedRecords.get(recordId); + if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) { + // 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。 return { source: "server", id: recordId }; } } const promise = saveGenerationRecordInternal(input); if (recordId) { - inFlightSaves.set(recordId, promise); + const saveKey = `${recordId}:${signature}`; + inFlightSaves.set(saveKey, promise); void promise .then((result) => { - if (result.source === "server") recentlySavedAt.set(recordId, Date.now()); + if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature }); }) .catch(() => undefined) .finally(() => { - inFlightSaves.delete(recordId); + inFlightSaves.delete(saveKey); }); } return promise; diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 23e4914..0ff6c0d 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -24,7 +24,7 @@ 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"; @@ -356,10 +356,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; @@ -376,6 +379,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; + detailModules: string[]; + modelScenes: string[]; + referenceImages: CloneImageItem[]; + replicateLevel: CloneReplicateLevelKey; + turns?: EcommerceHistoryTurn[]; +} + interface ProductSetPreviewSelection { src: string; label: string; @@ -1321,9 +1347,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 ?? [], @@ -1333,6 +1417,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() { @@ -1867,6 +1959,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const historyRefreshLockRef = useRef(false); const lastSavedHistorySignatureRef = useRef(""); const imageAbortRef = useRef({ current: false }); + const activeHistoryTurnIdRef = useRef(null); const activeEcommerceTaskIdsRef = useRef>(new Set()); const lastFailedActionRef = useRef<(() => void) | null>(null); const [garmentImages, setGarmentImages] = useState([]); @@ -1991,7 +2084,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"); @@ -3985,27 +4096,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" @@ -4024,19 +4158,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, - }]); + }); } }, ); @@ -4140,6 +4287,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { toast.success("已从当前视图移除"); }; + const upsertCanvasNode = (node: Omit) => { + 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); @@ -4322,46 +4486,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, @@ -4390,9 +4520,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); @@ -4418,32 +4711,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((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 }; @@ -4458,6 +4753,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setComposerMenu(null); setIsCommandComposerCompact(false); setActiveHistoryRecordId(null); + activeHistoryTurnIdRef.current = null; lastSavedHistorySignatureRef.current = ""; }; @@ -4496,7 +4792,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(() => {}); }; @@ -6842,26 +7141,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 (
-
- 需求 -

{historyRequirementText || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}

-
- {historyRequirementMeta.map((item) => ( - - {item.label} - {item.value} - - ))} -
- {historyConversationImages.length ? ( -
- {historyConversationImages.slice(0, 4).map((image) => ( - {image.name - ))} - {historyConversationImages.length > 4 ? +{historyConversationImages.length - 4} : null} -
- ) : null} -
-
- 电商图设计师 -

- {status === "done" || currentResultCount > 0 - ? `已生成 ${currentResultCount || results.length || productSetResultImages.filter(Boolean).length} 张结果,可在画布中拖拽、缩放和预览。` - : status === "generating" - ? `正在为 ${platform} / ${market} 生成${selectedCloneOutput.label},结果会自动出现在中间画布。` - : status === "failed" - ? "生成失败,请检查网络或参数后重试。" - : "我会根据商品图、平台规则和提示词整理生成任务。"} -

- {status === "generating" ? ( - - ) : null} - {currentResultThumbs.length ? ( -
- {currentResultThumbs.map((item) => ( - - ))} -
- ) : null} -
- {newConversationImages.length ? ( -
- 新需求 -

{newRequirementText}

-
- {currentRequirementMeta.map((item) => ( - - {item.label} - {item.value} - - ))} -
-
- {newConversationImages.slice(0, 4).map((image) => ( - {image.name - ))} - {newConversationImages.length > 4 ? +{newConversationImages.length - 4} : null} -
-
- ) : 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 ( + +
0 ? " clone-ai-chat-message--followup" : ""}`}> + {index === 0 ? "需求" : `继续生成 ${index + 1}`} +

{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}

+
+ {turnMeta.map((item) => ( + + {item.label} + {item.value} + + ))} +
+ {turn.productImages.length ? ( +
+ {turn.productImages.slice(0, 4).map((image) => ( + {image.name + ))} + {turn.productImages.length > 4 ? +{turn.productImages.length - 4} : null} +
+ ) : null} +
+
+ 电商图设计师 +

+ {turn.status === "done" || turnResults.length + ? `已生成 ${turnResults.length} 张${outputLabel},已同步到中间画布,可拖拽、缩放和预览。` + : turn.status === "generating" + ? `正在为 ${turn.platform} / ${turn.market} 生成${outputLabel},完成后会自动追加到画布。` + : turn.errorMessage || "生成失败,请检查网络或参数后重试。"} +

+ {isCurrentGeneratingTurn ? ( + + ) : null} + {turn.status === "failed" ? ( + + ) : null} + {turnResults.length ? ( +
+ {turnResults.slice(0, 6).map((item) => ( + + ))} +
+ ) : null} +
+
+ ); + })}