2c02735037
三个 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>
373 lines
17 KiB
TypeScript
373 lines
17 KiB
TypeScript
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,
|
||
};
|