320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
// 克隆 / 电商历史的本地持久化模块。
|
|
// 从 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<CloneSetCountKey, number>;
|
|
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<CloneSetCountKey, number>;
|
|
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<CloneSetCountKey, number>;
|
|
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<CloneSetCountKey, number> = {
|
|
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<CloneImageItem>;
|
|
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<CloneResult>;
|
|
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<EcommerceHistoryRecord>;
|
|
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<CloneSavedSetting>;
|
|
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)),
|
|
);
|
|
}
|