fix(ecommerce): 修复模板生成误用套图链路/退出登录失效/删除历史不回首页,并完成 EcommercePage 拆分
三个 bug 均为旧代码链路污染:
1. 点击热门/海报等模板后生成,误弹"将生成 N 张图片"套图确认框
- 根因:shouldConfirmSetCount 只判 effectiveOutput==="set",未排除场景路由的单图链路
- 改为仅在真正套图路径(!routedScenario && cloneOutput==="set")时确认
2. 头像弹窗内"退出"按钮点击无反应,无法退出登录
- 根因:Topbar header 内联 pointerEvents:"none",弹窗 section 及 backdrop
未像其它可点元素那样内联 pointerEvents:"auto",整棵弹窗子树继承 none
- 给 popover section 与 backdrop 补上内联 pointerEvents:"auto"
3. 删除当前查看的历史记录后停留在原任务页,未回到首页
- 删除 active 记录时改为镜像"新建对话"的复位(resetTask + 清画布/预览/指令栏)
附带完成 EcommercePage.tsx 拆分重构(8615→约7700行):模块级类型/常量/资源/
工具函数拆到 ecommerceTypes/Constants/JsxConstants/Assets/ImagePipeline/IntentClassifier
六个文件并改为 import;修正拆分文件两处 stale 分歧(maxCloneProductImages=10、
ProductClonePageProps.onWorkspaceChromeChange);并入历史记录按用户分桶修复。
验证:type-check 0 错 / 159 测试通过 / build 通过
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import type { EcommerceTemplateManifestItem } from "../../api/ecommerceTemplateClient";
|
||||
import type { EcommerceHistoryRecord } from "./utils/clonePersistence";
|
||||
import { normalizeEcommerceHistoryRecord } from "./utils/clonePersistence";
|
||||
import type { ProductSetOutputKey } from "./utils/platformRules";
|
||||
import type { CloneSetCountKey, CloneVideoQualityKey, CloneReplicateLevelKey } from "./utils/clonePersistence";
|
||||
import type {
|
||||
CommerceDefaultImageScenarioKey,
|
||||
CommerceDefaultIntent,
|
||||
CommerceScenarioKey,
|
||||
CommerceScenarioTemplate,
|
||||
} from "./ecommerceTypes";
|
||||
import { commerceScenarioOptions } from "./ecommerceJsxConstants";
|
||||
|
||||
/**
|
||||
* 模块级纯常量与纯函数(无 React / 无 I/O),从 EcommercePage.tsx 抽出。
|
||||
* 含 JSX 的常量(sideTools/commerceScenarioOptions/renderPlatformLogo)见 ecommerceConstants.tsx。
|
||||
*/
|
||||
|
||||
const smartCutoutColorPresets = [
|
||||
"#ffffff",
|
||||
"#111111",
|
||||
"#ff3131",
|
||||
"#ff7a1a",
|
||||
"#f7c600",
|
||||
"#29b34a",
|
||||
"#25a9e0",
|
||||
"#438df5",
|
||||
"#9029d9",
|
||||
"#8aa3ad",
|
||||
"#6b7b86",
|
||||
"#f46f7b",
|
||||
"#ff9451",
|
||||
"#f7d34f",
|
||||
"#55c66f",
|
||||
"#73c7f3",
|
||||
"#6dabf5",
|
||||
"#b45adb",
|
||||
"#bcc8ce",
|
||||
"#aeb7bd",
|
||||
"#ffbec4",
|
||||
"#ffd1ac",
|
||||
"#f8e69d",
|
||||
"#91de9e",
|
||||
"#b7e5fb",
|
||||
"#b9d9fb",
|
||||
"#d7abe8",
|
||||
"#dfe5e8",
|
||||
"#d7dde0",
|
||||
"#ffe2e4",
|
||||
"#ffe5d1",
|
||||
"#f8efcf",
|
||||
"#c9efcf",
|
||||
"#d8f0fb",
|
||||
"#d8eafa",
|
||||
"#ead2f1",
|
||||
];
|
||||
|
||||
const smartCutoutSizeOptions = [
|
||||
{ key: "original", label: "原尺寸", icon: "image", frameWidth: "min(520px, 78%)", frameAspect: "auto", imageMaxWidth: "78%", imageMaxHeight: "310px" },
|
||||
{ key: "trim", label: "裁剪到边缘", icon: "crop", frameWidth: "min(420px, 70%)", frameAspect: "auto", imageMaxWidth: "92%", imageMaxHeight: "360px" },
|
||||
{ key: "one-inch", label: "一寸头像", sizeLabel: "295*413", icon: "portrait", frameWidth: "min(290px, 50%)", frameAspect: "295 / 413", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 295, outputHeight: 413 },
|
||||
{ key: "two-inch", label: "二寸头像", sizeLabel: "413*579", icon: "portrait", frameWidth: "min(320px, 54%)", frameAspect: "413 / 579", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 413, outputHeight: 579 },
|
||||
{ key: "taobao-1-1", label: "淘宝1:1主图", sizeLabel: "800*800", icon: "shop", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 },
|
||||
{ key: "taobao-3-4", label: "淘宝3:4主图", sizeLabel: "750*1000", icon: "shop", frameWidth: "min(330px, 56%)", frameAspect: "750 / 1000", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 750, outputHeight: 1000 },
|
||||
{ key: "pdd-main", label: "拼多多主图", sizeLabel: "800*800", icon: "pdd", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 },
|
||||
{ key: "xiaohongshu-cover", label: "小红书封面", sizeLabel: "1242*1660", icon: "text", frameWidth: "min(330px, 56%)", frameAspect: "1242 / 1660", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 1242, outputHeight: 1660 },
|
||||
{ key: "ratio-1-1", label: "1:1", icon: "square", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||
{ key: "ratio-3-2", label: "3:2", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "3 / 2", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||
{ key: "ratio-2-3", label: "2:3", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "2 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||
{ key: "ratio-4-3", label: "4:3", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "4 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||
{ key: "ratio-3-4", label: "3:4", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||
{ key: "ratio-16-9", label: "16:9", icon: "wide", frameWidth: "min(560px, 82%)", frameAspect: "16 / 9", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||
{ key: "ratio-9-16", label: "9:16", icon: "phone", frameWidth: "min(260px, 46%)", frameAspect: "9 / 16", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||
] as const;
|
||||
|
||||
type SmartCutoutSizeKey = (typeof smartCutoutSizeOptions)[number]["key"];
|
||||
|
||||
const ecommerceInspirationTabs = ["最近打开", "一键同款", "海报模板", "热门", "商品图", "模特穿戴"];
|
||||
|
||||
// 把灵感卡片的标题 + 卖点要点合成一段可直接填入指令栏的提示词。
|
||||
const buildInspirationPrompt = (title: string, meta: string): string => {
|
||||
const points = meta
|
||||
.split(/[·、,,]/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const base = title.trim();
|
||||
return points.length ? `${base}。风格要点:${points.join("、")}。` : `${base}。`;
|
||||
};
|
||||
|
||||
const getPlatformLogoText = (value: string) => {
|
||||
const normalized = value.toLowerCase();
|
||||
if (value.includes("淘宝") || value.includes("天猫")) return "淘";
|
||||
if (value.includes("京东")) return "京";
|
||||
if (value.includes("拼多多") || value.includes("鎷煎澶")) return "拼";
|
||||
if (value.includes("抖音")) return "抖";
|
||||
if (normalized.includes("amazon")) return "a";
|
||||
if (normalized.includes("shopee")) return "S";
|
||||
if (normalized.includes("lazada")) return "L";
|
||||
if (normalized.includes("instagram")) return "IG";
|
||||
if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "AE";
|
||||
if (normalized.includes("ebay")) return "eB";
|
||||
if (normalized.includes("tiktok")) return "♪";
|
||||
return value.trim().slice(0, 1).toUpperCase() || "商";
|
||||
};
|
||||
const getPlatformLogoVariant = (value: string) => {
|
||||
const normalized = value.toLowerCase();
|
||||
if (value.includes("淘宝") || value.includes("天猫")) return "taobao";
|
||||
if (value.includes("京东")) return "jd";
|
||||
if (value.includes("拼多多") || value.includes("鎷煎澶")) return "pdd";
|
||||
if (value.includes("抖音")) return "douyin";
|
||||
if (normalized.includes("amazon")) return "amazon";
|
||||
if (normalized.includes("shopee")) return "shopee";
|
||||
if (normalized.includes("lazada")) return "lazada";
|
||||
if (normalized.includes("instagram")) return "instagram";
|
||||
if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "aliexpress";
|
||||
if (normalized.includes("ebay")) return "ebay";
|
||||
if (normalized.includes("tiktok")) return "tiktok";
|
||||
return "default";
|
||||
};
|
||||
const getPlatformLogoMarks = (value: string) => {
|
||||
if (value.includes("淘宝") || value.includes("天猫")) return ["淘", "猫"];
|
||||
return [getPlatformLogoText(value)];
|
||||
};
|
||||
|
||||
const primaryCommerceScenarioKeys: CommerceScenarioKey[] = ["popular", "poster", "mainImage", "model"];
|
||||
const scenarioSettingsKeys: CommerceScenarioKey[] = ["poster", "mainImage", "model", "scene", "festival", "salesVideo"];
|
||||
const scenarioAdvancedSettingsKeys: CommerceScenarioKey[] = ["model", "salesVideo"];
|
||||
const commerceScenarioOutputMap: Record<Exclude<CommerceScenarioKey, "popular">, ProductSetOutputKey> = {
|
||||
poster: "set",
|
||||
mainImage: "set",
|
||||
scene: "set",
|
||||
festival: "set",
|
||||
model: "model",
|
||||
background: "set",
|
||||
retouch: "set",
|
||||
salesVideo: "video",
|
||||
};
|
||||
|
||||
const ecommerceTemplateCategoryMap: Record<string, Exclude<CommerceScenarioKey, "popular">> = {
|
||||
poster: "poster",
|
||||
"main-image": "mainImage",
|
||||
"scene-image": "scene",
|
||||
"festival-image": "festival",
|
||||
"model-image": "model",
|
||||
"background-replace": "background",
|
||||
retouch: "retouch",
|
||||
"sales-video": "salesVideo",
|
||||
};
|
||||
|
||||
const getTemplateMediaType = (template: EcommerceTemplateManifestItem): "image" | "video" => {
|
||||
const extension = template.preview?.extension?.toLowerCase() || template.preview?.url?.split("?")[0].split(".").pop()?.toLowerCase() || "";
|
||||
return extension.includes("mp4") || extension.includes("webm") || extension.includes("mov") ? "video" : "image";
|
||||
};
|
||||
|
||||
const mapRemoteTemplateToScenarioTemplate = (template: EcommerceTemplateManifestItem): CommerceScenarioTemplate | null => {
|
||||
const scenario = ecommerceTemplateCategoryMap[String(template.categorySlug || "").trim()];
|
||||
const mediaUrl = template.preview?.url?.trim();
|
||||
if (!scenario || !template.id || !mediaUrl) return null;
|
||||
|
||||
const title = template.templateName?.trim() || template.templateSlug?.trim() || template.id;
|
||||
const prompt = template.prompt?.trim() || title;
|
||||
const sourceAssets = (template.assets || [])
|
||||
.filter((asset) => typeof asset.url === "string" && asset.url.trim())
|
||||
.map((asset, index) => {
|
||||
const url = asset.url!.trim();
|
||||
const extension = asset.extension?.replace(/^\./, "") || url.split("?")[0].split(".").pop() || "png";
|
||||
return {
|
||||
url,
|
||||
name: asset.fileName?.trim() || `${title}-素材${asset.assetIndex || index + 1}.${extension}`,
|
||||
ossKey: asset.ossKey,
|
||||
mimeType: extension.toLowerCase() === "jpg" || extension.toLowerCase() === "jpeg" ? "image/jpeg" : "image/png",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
scenario,
|
||||
output: commerceScenarioOutputMap[scenario],
|
||||
title,
|
||||
desc: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.desc || "",
|
||||
badge: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.label || title,
|
||||
prompt,
|
||||
mediaUrl,
|
||||
mediaType: getTemplateMediaType(template),
|
||||
sourceAssets,
|
||||
};
|
||||
};
|
||||
|
||||
const defaultCommerceIntentFallback: CommerceDefaultIntent = { kind: "image", scenario: "mainImage" };
|
||||
|
||||
const normalizeDefaultCommerceIntent = (value: unknown): CommerceDefaultIntent => {
|
||||
if (!value || typeof value !== "object") return defaultCommerceIntentFallback;
|
||||
const record = value as Record<string, unknown>;
|
||||
const kind = record.kind === "video" ? "video" : "image";
|
||||
const scenario = typeof record.scenario === "string" ? record.scenario : "";
|
||||
if (kind === "video" || scenario === "salesVideo") return { kind: "video", scenario: "salesVideo" };
|
||||
const imageScenarios: CommerceDefaultImageScenarioKey[] = ["poster", "mainImage", "scene", "festival", "model", "background", "retouch"];
|
||||
return imageScenarios.includes(scenario as CommerceDefaultImageScenarioKey)
|
||||
? { kind: "image", scenario: scenario as CommerceDefaultImageScenarioKey }
|
||||
: defaultCommerceIntentFallback;
|
||||
};
|
||||
|
||||
const commerceScenarioGenerationKind = (scenario: CommerceDefaultImageScenarioKey): "singleImage" | "imageEdit" =>
|
||||
scenario === "background" || scenario === "retouch" ? "imageEdit" : "singleImage";
|
||||
|
||||
const cloneSetCountOptions: Array<{
|
||||
key: CloneSetCountKey;
|
||||
title: string;
|
||||
desc: string;
|
||||
}> = [
|
||||
{ key: "selling", title: "卖点图", desc: "展示商品核心卖点和细节特写" },
|
||||
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
|
||||
{ key: "scene", title: "场景图", desc: "展示商品生活使用场景和人物搭配" },
|
||||
];
|
||||
const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key);
|
||||
const minCloneSetTotal = 1;
|
||||
const maxCloneSetTotal = 16;
|
||||
const maxCloneProductImages = 10;
|
||||
const maxCloneReferenceImages = 20;
|
||||
const cloneVideoDurationMin = 5;
|
||||
const cloneVideoDurationMax = 45;
|
||||
const composerDurationOptions = [5, 10, 15];
|
||||
const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [
|
||||
{ key: "standard", label: "标准", desc: "快速出片" },
|
||||
{ key: "high", label: "高清", desc: "推荐" },
|
||||
{ key: "ultra", label: "超清", desc: "细节增强" },
|
||||
];
|
||||
const cloneReplicateLevelOptions: Array<{ key: CloneReplicateLevelKey; title: string; desc: string }> = [
|
||||
{ key: "style", title: "参考风格", desc: "参考整体风格和结构,自动调整色彩和重构场景。" },
|
||||
{ key: "high", title: "高度复刻", desc: "参考视觉结构替换产品和文案,保留主要场景细节。" },
|
||||
];
|
||||
const tryOnRatioOptions = ["3:4", "1:1", "9:16"];
|
||||
const tryOnScenes = ["纯色棚拍", "都市街头", "街角咖啡", "自然草坪", "度假海滩", "温馨居家", "艺术展馆"];
|
||||
const normalizeCloneModelSceneSelection = (scenes: string[] | null | undefined) => {
|
||||
const validScenes = (scenes ?? []).filter((scene) => typeof scene === "string" && scene.trim());
|
||||
const latestScene = validScenes[validScenes.length - 1];
|
||||
return latestScene ? [latestScene] : [];
|
||||
};
|
||||
const tryOnModelOptions = {
|
||||
gender: ["女", "男"],
|
||||
age: ["青年", "少年", "中年"],
|
||||
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
|
||||
body: ["标准", "高挑", "微胖", "运动"],
|
||||
};
|
||||
|
||||
const detailTypeOptions = ["普通A+", "品牌A+", "标准详情页", "移动端长图"];
|
||||
const detailModules = [
|
||||
{ id: "hero", title: "首页焦点图", desc: "集中呈现核心利益点" },
|
||||
{ id: "selling", title: "卖点强化图", desc: "放大产品优势" },
|
||||
{ id: "usage", title: "使用情境图", desc: "还原实际使用画面" },
|
||||
{ id: "angle", title: "外观角度图", desc: "展示不同视角造型" },
|
||||
{ id: "scene", title: "氛围场景图", desc: "营造产品应用环境" },
|
||||
{ id: "detail", title: "细节特写图", desc: "突出材质和做工" },
|
||||
{ id: "story", title: "品牌理念图", desc: "表达品牌主张" },
|
||||
{ id: "size", title: "规格尺寸图", desc: "说明尺寸容量尺码" },
|
||||
{ id: "compare", title: "效果对照图", desc: "呈现前后差异" },
|
||||
{ id: "spec", title: "参数信息表", desc: "整理商品关键数据" },
|
||||
{ id: "craft", title: "工艺流程图", desc: "说明制作与处理步骤" },
|
||||
{ id: "gift", title: "清单配件图", desc: "展示包装内全部内容" },
|
||||
{ id: "series", title: "SKU组合图", desc: "呈现颜色款式组合" },
|
||||
{ id: "ingredient", title: "成分材质图", desc: "说明配方或材料构成" },
|
||||
{ id: "service", title: "保障说明图", desc: "传达质保退换承诺" },
|
||||
{ id: "tips", title: "使用提示图", desc: "提醒操作与保养要点" },
|
||||
];
|
||||
const defaultDetailModuleIds: string[] = [];
|
||||
const maxDetailModuleSelection = 6;
|
||||
const cloneDetailModules = detailModules;
|
||||
|
||||
function getImageFileFormat(file: File) {
|
||||
const mimeFormat = file.type.split("/")[1]?.replace("jpeg", "jpg").toUpperCase();
|
||||
if (mimeFormat) return mimeFormat;
|
||||
return file.name.split(".").pop()?.toUpperCase() ?? "";
|
||||
}
|
||||
|
||||
function getRemoteImageFormat(mimeType: string, imageUrl: string) {
|
||||
const mimeFormat = mimeType.split("/")[1]?.replace("jpeg", "jpg").toUpperCase();
|
||||
if (mimeFormat) return mimeFormat;
|
||||
return imageUrl.split("?")[0].split(".").pop()?.toUpperCase() ?? "IMAGE";
|
||||
}
|
||||
|
||||
function getRemoteImageName(imageUrl: string, fallback: string) {
|
||||
try {
|
||||
const parsed = new URL(imageUrl);
|
||||
const filename = decodeURIComponent(parsed.pathname.split("/").filter(Boolean).pop() || "");
|
||||
return filename || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function readImageDimensions(src: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
image.onerror = reject;
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
function clampCloneVideoDuration(value: number) {
|
||||
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
|
||||
}
|
||||
|
||||
function mergeEcommerceHistoryRecords(...recordGroups: EcommerceHistoryRecord[][]): EcommerceHistoryRecord[] {
|
||||
const recordsById = new Map<string, EcommerceHistoryRecord>();
|
||||
for (const records of recordGroups) {
|
||||
for (const record of records) {
|
||||
const normalized = normalizeEcommerceHistoryRecord(record);
|
||||
const existing = recordsById.get(normalized.id);
|
||||
if (!existing || normalized.createdAt >= existing.createdAt || normalized.turns?.length !== existing.turns?.length) {
|
||||
recordsById.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(recordsById.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30);
|
||||
}
|
||||
|
||||
export {
|
||||
smartCutoutColorPresets,
|
||||
smartCutoutSizeOptions,
|
||||
type SmartCutoutSizeKey,
|
||||
ecommerceInspirationTabs,
|
||||
buildInspirationPrompt,
|
||||
getPlatformLogoText,
|
||||
getPlatformLogoVariant,
|
||||
getPlatformLogoMarks,
|
||||
primaryCommerceScenarioKeys,
|
||||
scenarioSettingsKeys,
|
||||
scenarioAdvancedSettingsKeys,
|
||||
commerceScenarioOutputMap,
|
||||
ecommerceTemplateCategoryMap,
|
||||
getTemplateMediaType,
|
||||
mapRemoteTemplateToScenarioTemplate,
|
||||
defaultCommerceIntentFallback,
|
||||
normalizeDefaultCommerceIntent,
|
||||
commerceScenarioGenerationKind,
|
||||
cloneSetCountOptions,
|
||||
cloneSetCountKeys,
|
||||
minCloneSetTotal,
|
||||
maxCloneSetTotal,
|
||||
maxCloneProductImages,
|
||||
maxCloneReferenceImages,
|
||||
cloneVideoDurationMin,
|
||||
cloneVideoDurationMax,
|
||||
composerDurationOptions,
|
||||
cloneVideoQualityOptions,
|
||||
cloneReplicateLevelOptions,
|
||||
tryOnRatioOptions,
|
||||
tryOnScenes,
|
||||
normalizeCloneModelSceneSelection,
|
||||
tryOnModelOptions,
|
||||
detailTypeOptions,
|
||||
detailModules,
|
||||
defaultDetailModuleIds,
|
||||
maxDetailModuleSelection,
|
||||
cloneDetailModules,
|
||||
getImageFileFormat,
|
||||
getRemoteImageFormat,
|
||||
getRemoteImageName,
|
||||
readImageDimensions,
|
||||
blobToDataUrl,
|
||||
clampCloneVideoDuration,
|
||||
mergeEcommerceHistoryRecords,
|
||||
};
|
||||
Reference in New Issue
Block a user