// 克隆 / 电商历史的本地持久化模块。 // 从 EcommercePage.tsx 抽出,逻辑零改动。 // 把 localStorage 读写 + 字段校验 + 默认值收口在此,页面只调用 read/write。 // // 领域类型(CloneImageItem / CloneResult / CloneSavedSetting / EcommerceHistoryRecord // 及其依赖的 type alias)也定义在此并 export,因为它们本质上是"持久化数据契约"; // EcommercePage 从这里 re-import,避免循环依赖(类型 import 编译期擦除)。 import type { CloneOutputKey } from "./platformRules"; export type CloneSetCountKey = "selling" | "white" | "scene"; export type CloneModelPanelTab = "scene" | "model"; export type CloneVideoQualityKey = "standard" | "high" | "ultra"; export type CloneReplicateLevelKey = "style" | "high"; export type CloneReferenceMode = "upload" | "link"; export interface CloneImageItem { id: string; src: string; name: string; file?: File; width?: number; height?: number; format?: string; mimeType?: string; ossKey?: string; } export interface CloneResult { id: string; src: string; label: string; type?: "image" | "video"; } export interface CloneSavedSetting { id: string; name: string; savedAt: string; output: CloneOutputKey; platform: string; market: string; language: string; ratio: string; setCounts: Record; detailModules: string[]; modelPanelTab: CloneModelPanelTab; modelScenes: string[]; modelCustomScene: string; modelGender: string; modelAge: string; modelEthnicity: string; modelBody: string; modelAppearance: string; videoQuality: CloneVideoQualityKey; videoDurationSeconds: number; videoSmart: boolean; referenceMode?: CloneReferenceMode; replicateLevel?: CloneReplicateLevelKey; requirement: string; } export type EcommerceHistoryStatus = "generating" | "done" | "failed"; export interface EcommerceHistoryTurn { id: string; createdAt: number; status: EcommerceHistoryStatus; errorMessage?: string; output: CloneOutputKey; modeLabel?: string; settingLabel?: string; generationKind?: "singleImage" | "imageEdit" | "imageSet" | "video"; 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; } export interface EcommerceHistoryRecord { id: string; title: string; createdAt: number; status?: EcommerceHistoryStatus; errorMessage?: string; output: CloneOutputKey; modeLabel?: string; settingLabel?: string; generationKind?: "singleImage" | "imageEdit" | "imageSet" | "video"; 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[]; } export const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; export const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records"; export const defaultCloneSetCounts: Record = { selling: 3, white: 1, scene: 3, }; export const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; export function isCloneImageItem(item: unknown): item is CloneImageItem { const candidate = item as Partial; return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.name === "string"; } export function isCloneResult(item: unknown): item is CloneResult { const candidate = item as Partial; return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.label === "string"; } export function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord { const candidate = item as Partial; return ( typeof candidate.id === "string" && typeof candidate.title === "string" && typeof candidate.createdAt === "number" && typeof candidate.output === "string" && typeof candidate.platform === "string" && typeof candidate.market === "string" && typeof candidate.language === "string" && typeof candidate.ratio === "string" && typeof candidate.requirement === "string" && Array.isArray(candidate.productImages) && candidate.productImages.every(isCloneImageItem) && Array.isArray(candidate.results) && candidate.results.every(isCloneResult) ); } export function isCloneSavedSetting(item: unknown): item is CloneSavedSetting { const candidate = item as Partial; return ( typeof candidate.id === "string" && typeof candidate.name === "string" && typeof candidate.savedAt === "string" && typeof candidate.output === "string" && typeof candidate.platform === "string" && typeof candidate.market === "string" && typeof candidate.language === "string" && typeof candidate.ratio === "string" && typeof candidate.videoDurationSeconds === "number" ); } export function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[] { return images.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ id, src, name, width, height, format, mimeType, ossKey, })); } export 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}` })); } export 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, modeLabel: record.modeLabel, settingLabel: record.settingLabel, generationKind: record.generationKind, 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", }; } export 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, modeLabel: turn.modeLabel ?? fallback.modeLabel, settingLabel: turn.settingLabel ?? fallback.settingLabel, generationKind: turn.generationKind ?? fallback.generationKind, 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", }; } export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord { const status = record.status ?? "done"; const baseRecord = { ...record, status, errorMessage: status === "failed" ? record.errorMessage : undefined, modeLabel: record.modeLabel, settingLabel: record.settingLabel, generationKind: record.generationKind, productImages: removeFilePayloadFromImages(record.productImages), referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []), results: record.results ?? [], setResultImages: record.setResultImages ?? [], setCounts: record.setCounts ?? defaultCloneSetCounts, detailModules: record.detailModules ?? defaultCloneDetailModuleIds, 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, }; } export function readCloneLatestSetting(): CloneSavedSetting | null { if (typeof window === "undefined") return null; try { const rawValue = window.localStorage.getItem(cloneLatestSettingStorageKey); if (rawValue) { const parsedValue: unknown = JSON.parse(rawValue); if (isCloneSavedSetting(parsedValue)) return parsedValue; } } catch { return null; } return null; } export function writeCloneLatestSetting(setting: CloneSavedSetting): void { if (typeof window === "undefined") return; window.localStorage.setItem(cloneLatestSettingStorageKey, JSON.stringify(setting)); } export function clearCloneLatestSetting(): void { if (typeof window === "undefined") return; window.localStorage.removeItem(cloneLatestSettingStorageKey); } export function readEcommerceHistoryRecords(): EcommerceHistoryRecord[] { if (typeof window === "undefined") return []; try { const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey); if (!rawValue) return []; const parsedValue: unknown = JSON.parse(rawValue); if (!Array.isArray(parsedValue)) return []; return parsedValue .filter(isEcommerceHistoryRecord) .map(normalizeEcommerceHistoryRecord) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 30); } catch { return []; } } export function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]): void { if (typeof window === "undefined") return; window.localStorage.setItem( ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)), ); }