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:
@@ -138,9 +138,10 @@ export function Topbar({
|
||||
type="button"
|
||||
className="ecommerce-profile-popover__backdrop"
|
||||
aria-label="关闭账户信息"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
onClick={() => onProfileMenuOpenChange(false)}
|
||||
/>
|
||||
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
|
||||
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息" style={{ pointerEvents: "auto" }}>
|
||||
<div className="ecommerce-profile-popover__head">
|
||||
<LocalAvatar session={session} size="md" />
|
||||
<div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import type { CommerceScenarioTemplate } from "./ecommerceTypes";
|
||||
|
||||
/**
|
||||
* 依赖 OSS 资源的数据切片与模板常量,从 EcommercePage.tsx 抽出。
|
||||
* 所有 mediaUrl 均来自 ossAssets.ecommerce.*,符合 AGENTS.md「图片只走 OSS」规则。
|
||||
*/
|
||||
|
||||
const ecommerceInspirationAssets = ossAssets.ecommerce.inspiration;
|
||||
|
||||
const ecommerceInspirationRows = [
|
||||
{
|
||||
title: "作品记录",
|
||||
desc: "沉淀最近生成的高转化素材,随时回看与复用。",
|
||||
variant: "team",
|
||||
cards: [
|
||||
{ title: "指定ASIN,优化listing", meta: "竞品拆解 · 卖点重排 · 图文建议", mediaUrl: ecommerceInspirationAssets.asinListing, mediaType: "image" },
|
||||
{ title: "TikTok美区爆品分析", meta: "脚本方向 · 人群洞察 · 素材策略", mediaUrl: ecommerceInspirationAssets.tiktokPreference, mediaType: "image" },
|
||||
{ title: "竞品分析 + 全套listing", meta: "关键词 · 主图结构 · 转化建议", mediaUrl: ecommerceInspirationAssets.competitorListing, mediaType: "image" },
|
||||
{ title: "世界杯属性快闪视频", meta: "热点追踪 · 模板复用 · 15秒短片", mediaUrl: ecommerceInspirationAssets.worldCupFlashVideo, mediaType: "video" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "电商套图",
|
||||
desc: "主图 / 详情图全套一次性生成。",
|
||||
variant: "listing",
|
||||
cards: [
|
||||
{ title: "科技礼盒主图", meta: "高反差质感 · 参数卖点", mediaUrl: ecommerceInspirationAssets.officeStyleSet, mediaType: "image" },
|
||||
{ title: "美妆节日套图", meta: "促销氛围 · 多规格展示", mediaUrl: ecommerceInspirationAssets.fathersDaySet, mediaType: "image" },
|
||||
{ title: "防晒产品场景", meta: "户外光感 · 功效表达", mediaUrl: ecommerceInspirationAssets.sprayScene, mediaType: "image" },
|
||||
{ title: "露营家具详情", meta: "场景组合 · 尺寸说明", mediaUrl: ecommerceInspirationAssets.campingCart, mediaType: "image" },
|
||||
{ title: "香氛A+页面", meta: "材质细节 · 品牌氛围", mediaUrl: ecommerceInspirationAssets.perfumeSet, mediaType: "image" },
|
||||
{ title: "童装listing组合", meta: "多角度 · 人群展示", mediaUrl: ecommerceInspirationAssets.cosmeticApplication, mediaType: "image" },
|
||||
{ title: "高考文具淘宝套图", meta: "文具套装 · 淘宝主图 · 卖点陈列", mediaUrl: ecommerceInspirationAssets.stationeryTaobaoSet, mediaType: "image" },
|
||||
{ title: "条纹单人沙发套图", meta: "家居场景 · 多角度展示 · 软装质感", mediaUrl: ecommerceInspirationAssets.stripedSingleSofaSet, mediaType: "image" },
|
||||
{ title: "棕色皮夹克照片集", meta: "服饰套图 · 质感细节 · 穿搭展示", mediaUrl: ecommerceInspirationAssets.brownLeatherJacketPhotoSet, mediaType: "image" },
|
||||
{ title: "防晒帽模特佩戴", meta: "真人试戴 · 户外防晒 · 穿戴效果", mediaUrl: ecommerceInspirationAssets.modelSunHatTryon, mediaType: "image" },
|
||||
{ title: "淘宝耳机商品图", meta: "数码主图 · 参数卖点 · 平台套图", mediaUrl: ecommerceInspirationAssets.taobaoEarphoneProduct, mediaType: "image" },
|
||||
{ title: "Etsy香薰蜡烛套图", meta: "香氛氛围 · 手作质感 · 跨境陈列", mediaUrl: ecommerceInspirationAssets.etsyScentedCandleSet, mediaType: "image" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "商品视频",
|
||||
desc: "口播模拟 / 商品展示视频 / 社媒短片。",
|
||||
variant: "video",
|
||||
cards: [
|
||||
{ title: "口播种草短片", meta: "手持展示 · 真实推荐", mediaUrl: ecommerceInspirationAssets.spokenReview, mediaType: "video" },
|
||||
{ title: "香水质感视频", meta: "光影旋转 · 高级静物", mediaUrl: ecommerceInspirationAssets.perfumeTexture, mediaType: "video" },
|
||||
{ title: "玩具互动短视频", meta: "生活场景 · 情绪表达", mediaUrl: ecommerceInspirationAssets.toyInteraction, mediaType: "video" },
|
||||
{ title: "器皿产品展示", meta: "极简背景 · 材质突出", mediaUrl: ecommerceInspirationAssets.vesselDisplay, mediaType: "video" },
|
||||
{ title: "饰品模特试戴", meta: "近景特写 · 搭配建议", mediaUrl: ecommerceInspirationAssets.jewelryModel, mediaType: "video" },
|
||||
{ title: "包袋生活方式", meta: "室内场景 · 组合展示", mediaUrl: ecommerceInspirationAssets.sofaLifestyle, mediaType: "video" },
|
||||
{ title: "口红TikTok带货", meta: "UGC口播 · 真实推荐 · 社媒转化", mediaUrl: ecommerceInspirationAssets.lipstickUgcTiktokVideo, mediaType: "video" },
|
||||
{ title: "小夜灯抖音开箱", meta: "开箱种草 · 暖光氛围 · 竖版短片", mediaUrl: ecommerceInspirationAssets.nightLightUnboxingDouyin, mediaType: "video" },
|
||||
{ title: "清洁剂痛点解决", meta: "问题演示 · 功效对比 · 抖音素材", mediaUrl: ecommerceInspirationAssets.cleanerPainpointDouyin, mediaType: "video" },
|
||||
{ title: "连衣裙穿搭视频", meta: "服饰上身 · 场景走动 · 穿搭展示", mediaUrl: ecommerceInspirationAssets.dressOutfitVideo, mediaType: "video" },
|
||||
{ title: "防晒霜TikTok种草", meta: "UGC测评 · 户外防晒 · 平台短片", mediaUrl: ecommerceInspirationAssets.sunscreenUgcTiktokVideo, mediaType: "video" },
|
||||
{ title: "世界杯属性快闪", meta: "热点短片 · 节奏快闪 · 活动素材", mediaUrl: ecommerceInspirationAssets.worldCupFlashVideo, mediaType: "video" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const sampleResults = [
|
||||
ossAssets.ecommerce.slides.slide4,
|
||||
ossAssets.ecommerce.generated,
|
||||
ossAssets.ecommerce.slides.slide5,
|
||||
];
|
||||
const productSetAssets = ossAssets.ecommerce.productSet;
|
||||
const productSetPreviewCards = [
|
||||
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
|
||||
{ id: "scene", label: "02 场景展示", src: productSetAssets.scene },
|
||||
{ id: "model", label: "03 模特场景图", src: productSetAssets.model },
|
||||
{ id: "detail", label: "04 细节说明", src: productSetAssets.detail },
|
||||
{ id: "selling", label: "05 卖点详解", src: productSetAssets.selling },
|
||||
];
|
||||
const tryOnAssets = ossAssets.ecommerce.tryOn;
|
||||
|
||||
const tryOnCards = [
|
||||
{
|
||||
title: "多件混搭自动融合",
|
||||
tone: "red",
|
||||
inputs: [tryOnAssets.dressA, tryOnAssets.dressB, tryOnAssets.modelWoman],
|
||||
results: [tryOnAssets.tryA, tryOnAssets.tryB],
|
||||
},
|
||||
{
|
||||
title: "一件也能出大片",
|
||||
tone: "brown",
|
||||
inputs: [tryOnAssets.jacket, tryOnAssets.modelMan],
|
||||
results: [tryOnAssets.jacketResultA, tryOnAssets.jacketResultB],
|
||||
},
|
||||
{
|
||||
title: "鞋帽饰品完美适配",
|
||||
tone: "gold",
|
||||
inputs: [tryOnAssets.hat, tryOnAssets.modelAsian],
|
||||
results: [tryOnAssets.hatResultA, tryOnAssets.hatResultB],
|
||||
},
|
||||
];
|
||||
|
||||
const detailAssets = ossAssets.ecommerce.detail;
|
||||
const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC];
|
||||
const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF];
|
||||
|
||||
const commerceScenarioTemplates: CommerceScenarioTemplate[] = [
|
||||
{
|
||||
id: "poster-campaign-clean",
|
||||
scenario: "poster",
|
||||
output: "set",
|
||||
title: "新品活动海报",
|
||||
desc: "适合首发、上新、促销专题的主视觉",
|
||||
badge: "高频推荐",
|
||||
prompt: "帮我生成一张电商新品活动海报,突出产品主体、核心卖点和促销氛围,画面干净高级,适合店铺首页和广告投放。",
|
||||
mediaUrl: ossAssets.ecommerce.detail.longPage,
|
||||
},
|
||||
{
|
||||
id: "poster-social-drop",
|
||||
scenario: "poster",
|
||||
output: "set",
|
||||
title: "社媒种草海报",
|
||||
desc: "更适合小红书、朋友圈、站外广告",
|
||||
badge: "热门模板",
|
||||
prompt: "生成一张社媒种草风格商品海报,突出产品质感、生活方式和一句清晰卖点,画面轻盈、有品牌感。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.officeStyleSet,
|
||||
},
|
||||
{
|
||||
id: "main-clean-product",
|
||||
scenario: "mainImage",
|
||||
output: "set",
|
||||
title: "高转化商品主图",
|
||||
desc: "白底/浅场景,主体清楚,卖点明确",
|
||||
badge: "高频推荐",
|
||||
prompt: "生成一张高转化商品主图,产品主体居中清晰,背景简洁,突出核心卖点和材质细节,适合电商搜索列表展示。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.main,
|
||||
},
|
||||
{
|
||||
id: "main-selling-point",
|
||||
scenario: "mainImage",
|
||||
output: "set",
|
||||
title: "卖点强化主图",
|
||||
desc: "适合列表点击率优化",
|
||||
badge: "点击率优先",
|
||||
prompt: "生成一张卖点强化商品主图,保留产品真实质感,加入清晰卖点表达和轻量信息层级,适合提升列表点击率。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.selling,
|
||||
},
|
||||
{
|
||||
id: "scene-lifestyle",
|
||||
scenario: "scene",
|
||||
output: "set",
|
||||
title: "生活方式场景图",
|
||||
desc: "把商品放进真实使用环境",
|
||||
badge: "高频推荐",
|
||||
prompt: "生成生活方式商品场景图,把产品自然放入真实使用环境,突出使用感、氛围和购买理由,画面真实且商业化。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.scene,
|
||||
},
|
||||
{
|
||||
id: "scene-premium",
|
||||
scenario: "scene",
|
||||
output: "set",
|
||||
title: "高级质感场景",
|
||||
desc: "适合品牌调性和详情页氛围图",
|
||||
badge: "品牌感",
|
||||
prompt: "生成高级质感商品场景图,背景克制、光影柔和,突出产品材质、轮廓和品牌调性,适合详情页和广告素材。",
|
||||
mediaUrl: ossAssets.ecommerce.detail.gridA,
|
||||
},
|
||||
{
|
||||
id: "festival-seasonal",
|
||||
scenario: "festival",
|
||||
output: "set",
|
||||
title: "节日营销图",
|
||||
desc: "适合大促、节庆、节点活动",
|
||||
badge: "节点营销",
|
||||
prompt: "生成节日营销风格商品图,结合节日氛围和促销视觉,但保持产品主体清晰、信息不过载,适合电商活动投放。",
|
||||
mediaUrl: ossAssets.ecommerce.detail.gridB,
|
||||
},
|
||||
{
|
||||
id: "festival-gift",
|
||||
scenario: "festival",
|
||||
output: "set",
|
||||
title: "礼赠氛围图",
|
||||
desc: "适合礼盒、礼品、节日送礼场景",
|
||||
badge: "热门模板",
|
||||
prompt: "生成礼赠氛围商品图,突出节日送礼感、包装质感和温暖情绪,画面高级克制,适合活动页与社媒投放。",
|
||||
mediaUrl: ossAssets.ecommerce.detail.gridC,
|
||||
},
|
||||
{
|
||||
id: "model-natural-fit",
|
||||
scenario: "model",
|
||||
output: "model",
|
||||
title: "自然穿搭模特图",
|
||||
desc: "突出上身效果、版型和真实穿着",
|
||||
badge: "高频推荐",
|
||||
prompt: "生成自然穿搭模特图,突出服饰上身效果、版型和整体气质,模特姿态自然,适合服饰电商详情与主图展示。",
|
||||
mediaUrl: ossAssets.ecommerce.tryOn.dressA,
|
||||
},
|
||||
{
|
||||
id: "model-street",
|
||||
scenario: "model",
|
||||
output: "model",
|
||||
title: "街拍模特场景",
|
||||
desc: "更适合年轻化、生活方式品牌",
|
||||
badge: "风格推荐",
|
||||
prompt: "生成街拍风格模特图,模特自然展示商品,背景有生活气息,突出穿搭氛围、比例和品牌调性。",
|
||||
mediaUrl: ossAssets.ecommerce.tryOn.modelWoman,
|
||||
},
|
||||
{
|
||||
id: "background-clean",
|
||||
scenario: "background",
|
||||
output: "set",
|
||||
title: "商品换浅色背景",
|
||||
desc: "保留主体,重构干净商业背景",
|
||||
badge: "高频推荐",
|
||||
prompt: "为商品更换干净浅色商业背景,保留产品主体、边缘和材质细节,整体画面适合电商主图和广告素材。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.detail,
|
||||
},
|
||||
{
|
||||
id: "background-scene",
|
||||
scenario: "background",
|
||||
output: "set",
|
||||
title: "商品换场景背景",
|
||||
desc: "从普通拍摄变成真实使用场景",
|
||||
badge: "场景增强",
|
||||
prompt: "为商品更换真实使用场景背景,保持主体比例和边缘自然,增强生活化氛围和商业转化感。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.scene,
|
||||
},
|
||||
{
|
||||
id: "retouch-clean",
|
||||
scenario: "retouch",
|
||||
output: "set",
|
||||
title: "白底精修图",
|
||||
desc: "修正瑕疵、增强质感和边缘细节",
|
||||
badge: "高频推荐",
|
||||
prompt: "对商品图进行无痕精修,清理瑕疵、优化光影和边缘细节,保持商品真实结构,输出干净高级的电商图。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.main,
|
||||
},
|
||||
{
|
||||
id: "retouch-premium",
|
||||
scenario: "retouch",
|
||||
output: "set",
|
||||
title: "质感增强图",
|
||||
desc: "强化材质、反光和商品高级感",
|
||||
badge: "精修模板",
|
||||
prompt: "对商品图进行质感增强,强化材质、光泽、纹理和立体感,画面自然不过度修饰,适合商业广告素材。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.selling,
|
||||
},
|
||||
{
|
||||
id: "sales-video-hook",
|
||||
scenario: "salesVideo",
|
||||
output: "video",
|
||||
title: "带货视频开场",
|
||||
desc: "第一秒抓住注意力,快速进入卖点",
|
||||
badge: "高频推荐",
|
||||
prompt: "生成电商带货短视频脚本和分镜,第一秒突出产品和痛点,随后展示核心卖点、使用场景和行动引导。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.tiktokPreference,
|
||||
},
|
||||
{
|
||||
id: "sales-video-demo",
|
||||
scenario: "salesVideo",
|
||||
output: "video",
|
||||
title: "使用演示视频",
|
||||
desc: "适合讲解型、种草型短视频",
|
||||
badge: "转化优先",
|
||||
prompt: "生成商品使用演示短视频分镜,围绕使用过程、关键卖点和效果对比展开,节奏清晰,适合带货转化。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.asinListing,
|
||||
},
|
||||
{
|
||||
id: "poster-festival-gift",
|
||||
scenario: "poster",
|
||||
output: "set",
|
||||
title: "节日礼赠海报",
|
||||
desc: "适合父亲节、母亲节等节点礼赠氛围",
|
||||
badge: "节点营销",
|
||||
prompt: "生成一张节日礼赠风格电商海报,突出礼盒质感、温馨氛围和送礼情绪,画面高级克制,适合节日活动投放。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.fathersDaySet,
|
||||
},
|
||||
{
|
||||
id: "poster-luxury-perfume",
|
||||
scenario: "poster",
|
||||
output: "set",
|
||||
title: "奢品香水海报",
|
||||
desc: "高端质感,适合美妆香氛品牌",
|
||||
badge: "品牌感",
|
||||
prompt: "生成一张奢品香水电商海报,突出瓶身质感、光影层次和高端氛围,画面精致有艺术感,适合品牌旗舰店投放。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.perfumeSet,
|
||||
},
|
||||
{
|
||||
id: "main-image-model",
|
||||
scenario: "mainImage",
|
||||
output: "set",
|
||||
title: "模特展示主图",
|
||||
desc: "真人上身,提升列表点击率",
|
||||
badge: "点击率优先",
|
||||
prompt: "生成一张真人模特展示商品主图,突出上身效果、版型和搭配,背景简洁,适合提升搜索列表点击率和转化。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.model,
|
||||
},
|
||||
{
|
||||
id: "main-image-detail",
|
||||
scenario: "mainImage",
|
||||
output: "set",
|
||||
title: "细节质感主图",
|
||||
desc: "材质特写,强化购买信心",
|
||||
badge: "转化优先",
|
||||
prompt: "生成一张商品细节质感主图,突出材质纹理、工艺细节和真实触感,画面聚焦主体,适合强化用户购买信心。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.detail,
|
||||
},
|
||||
{
|
||||
id: "model-jacket",
|
||||
scenario: "model",
|
||||
output: "model",
|
||||
title: "男装夹克模特",
|
||||
desc: "硬朗风格,突出版型和质感",
|
||||
badge: "风格推荐",
|
||||
prompt: "生成男装夹克模特展示图,模特姿态自然有型,突出夹克版型、面料质感和整体搭配,适合男装电商详情和主图。",
|
||||
mediaUrl: ossAssets.ecommerce.tryOn.jacketResultA,
|
||||
},
|
||||
{
|
||||
id: "model-hat",
|
||||
scenario: "model",
|
||||
output: "model",
|
||||
title: "帽子配饰模特",
|
||||
desc: "细节展示,适合配饰品类",
|
||||
badge: "高频推荐",
|
||||
prompt: "生成帽子配饰模特展示图,突出帽型、佩戴效果和搭配细节,模特姿态自然,适合配饰、帽饰电商详情与主图。",
|
||||
mediaUrl: ossAssets.ecommerce.tryOn.hatResultA,
|
||||
},
|
||||
{
|
||||
id: "scene-camping",
|
||||
scenario: "scene",
|
||||
output: "set",
|
||||
title: "户外露营场景",
|
||||
desc: "把商品放进自然野趣环境",
|
||||
badge: "生活方式",
|
||||
prompt: "生成户外露营风格商品场景图,把产品自然融入露营环境,突出使用场景、自由氛围和生活品质,适合户外品类推广。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.campingCart,
|
||||
},
|
||||
{
|
||||
id: "scene-beauty-spray",
|
||||
scenario: "scene",
|
||||
output: "set",
|
||||
title: "美妆喷雾场景",
|
||||
desc: "捕捉使用瞬间,增强氛围感",
|
||||
badge: "氛围感",
|
||||
prompt: "生成美妆喷雾使用场景图,捕捉产品使用瞬间和细腻喷雾,突出清爽感、仪式感和大片氛围,适合美妆护肤品类。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.sprayScene,
|
||||
},
|
||||
{
|
||||
id: "festival-fathers-gift",
|
||||
scenario: "festival",
|
||||
output: "set",
|
||||
title: "父亲节礼盒图",
|
||||
desc: "礼赠场景,适合节日送礼营销",
|
||||
badge: "父亲节",
|
||||
prompt: "生成父亲节礼赠风格商品图,突出礼盒质感、沉稳色调和送礼仪式感,画面温暖有格调,适合父亲节活动投放。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.fathersDaySet,
|
||||
},
|
||||
{
|
||||
id: "festival-candle-gift",
|
||||
scenario: "festival",
|
||||
output: "set",
|
||||
title: "香薰蜡烛礼盒",
|
||||
desc: "温暖氛围,适合节日礼赠场景",
|
||||
badge: "热门模板",
|
||||
prompt: "生成香薰蜡烛节日礼盒图,突出温暖烛光、包装质感和治愈氛围,画面柔和高级,适合节日礼赠和家居品类营销。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.etsyScentedCandleSet,
|
||||
},
|
||||
{
|
||||
id: "background-premium-gray",
|
||||
scenario: "background",
|
||||
output: "set",
|
||||
title: "高级灰背景",
|
||||
desc: "简约商业,提升产品高级感",
|
||||
badge: "高频推荐",
|
||||
prompt: "为商品更换高级灰商业背景,保留产品主体和细节,背景简约有层次,突出产品轮廓和质感,适合电商主图和广告。",
|
||||
mediaUrl: ossAssets.ecommerce.detail.productA,
|
||||
},
|
||||
{
|
||||
id: "background-home-living",
|
||||
scenario: "background",
|
||||
output: "set",
|
||||
title: "居家背景",
|
||||
desc: "温馨生活场景,增强代入感",
|
||||
badge: "场景增强",
|
||||
prompt: "为商品更换温馨居家背景,保持主体自然融入,增强生活气息和使用代入感,适合家居、日用和生活方式品类。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.hosting,
|
||||
},
|
||||
{
|
||||
id: "retouch-color-correction",
|
||||
scenario: "retouch",
|
||||
output: "set",
|
||||
title: "色彩统一精修",
|
||||
desc: "多色校正,保持系列一致",
|
||||
badge: "精修模板",
|
||||
prompt: "对商品图进行色彩统一精修,校正色偏、统一光影和色调,保持系列素材一致性,画面自然真实,适合电商套图。",
|
||||
mediaUrl: ossAssets.ecommerce.detail.productB,
|
||||
},
|
||||
{
|
||||
id: "retouch-detail-sharpen",
|
||||
scenario: "retouch",
|
||||
output: "set",
|
||||
title: "细节锐化精修",
|
||||
desc: "纹理增强,提升商品质感",
|
||||
badge: "高频推荐",
|
||||
prompt: "对商品图进行细节锐化精修,增强纹理、边缘和材质细节,保持自然不过度,画面干净高级,适合主图和详情页。",
|
||||
mediaUrl: ossAssets.ecommerce.productSet.detail,
|
||||
},
|
||||
{
|
||||
id: "sales-video-painpoint",
|
||||
scenario: "salesVideo",
|
||||
output: "video",
|
||||
title: "痛点种草视频",
|
||||
desc: "直击痛点,快速建立购买动机",
|
||||
badge: "转化优先",
|
||||
prompt: "生成痛点种草风格带货短视频脚本和分镜,先抛出生活痛点再展示产品解决方案,节奏紧凑,适合清洁家电和功能性产品。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.cleanerPainpointDouyin,
|
||||
},
|
||||
{
|
||||
id: "sales-video-unboxing",
|
||||
scenario: "salesVideo",
|
||||
output: "video",
|
||||
title: "温馨开箱视频",
|
||||
desc: "氛围产品,增强情感连接",
|
||||
badge: "热门模板",
|
||||
prompt: "生成温馨开箱风格带货短视频脚本和分镜,围绕拆箱仪式感、产品外观和初体验展开,画面温暖治愈,适合氛围类产品。",
|
||||
mediaUrl: ossAssets.ecommerce.inspiration.nightLightUnboxingDouyin,
|
||||
},
|
||||
];
|
||||
|
||||
export {
|
||||
ecommerceInspirationAssets,
|
||||
ecommerceInspirationRows,
|
||||
sampleResults,
|
||||
productSetAssets,
|
||||
productSetPreviewCards,
|
||||
tryOnAssets,
|
||||
tryOnCards,
|
||||
detailAssets,
|
||||
detailProductSamples,
|
||||
detailGridSamples,
|
||||
commerceScenarioTemplates,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { toast } from "../../components/toast/toastStore";
|
||||
import type { CloneImageItem } from "./utils/clonePersistence";
|
||||
import { ecommerceOssScopes } from "./ecommerceGenerationPersistence";
|
||||
import {
|
||||
normalizeEcommerceImageMime,
|
||||
summarizeRejectedImages,
|
||||
validateEcommerceImageFiles,
|
||||
} from "./ecommerceImageValidation";
|
||||
import { getImageFileFormat, readImageDimensions } from "./ecommerceConstants";
|
||||
|
||||
/**
|
||||
* 图片上传/持久化/校验工具,从 EcommercePage.tsx 抽出。
|
||||
* 涉及网络 I/O(aiGenerationClient)与副作用(toast),按 AGENTS.md 走应用 API 上传至 OSS。
|
||||
*/
|
||||
|
||||
function createLocalImageItems(files: File[], limit: number, prefix: string): CloneImageItem[] {
|
||||
const selectedFiles = Array.from(files).slice(0, limit);
|
||||
const stamp = Date.now();
|
||||
return selectedFiles.map((file, index) => {
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
const mimeType = normalizeEcommerceImageMime(file.type);
|
||||
return {
|
||||
id: `${prefix}-${stamp}-${index}`,
|
||||
src: localPreviewUrl,
|
||||
name: file.name,
|
||||
file,
|
||||
format: getImageFileFormat(file),
|
||||
mimeType,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadImageItem(item: CloneImageItem): Promise<{ src?: string; ossKey?: string; width?: number; height?: number }> {
|
||||
if (!item.file) return {};
|
||||
const mimeType = normalizeEcommerceImageMime(item.file.type);
|
||||
try {
|
||||
const uploadBlob = item.file.type === mimeType ? item.file : new Blob([item.file], { type: mimeType });
|
||||
const [uploaded, dimensions] = await Promise.all([
|
||||
aiGenerationClient.uploadAssetBinary(uploadBlob, {
|
||||
name: item.file.name,
|
||||
mimeType,
|
||||
scope: ecommerceOssScopes.productSource,
|
||||
}),
|
||||
readImageDimensions(item.src).catch(() => ({})),
|
||||
]);
|
||||
return { src: uploaded.url, ossKey: uploaded.ossKey, ...dimensions };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
|
||||
const selectedFiles = Array.from(files).slice(0, limit);
|
||||
const stamp = Date.now();
|
||||
|
||||
const items = await Promise.all(selectedFiles.map(async (file, index) => {
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
let src = localPreviewUrl;
|
||||
let ossKey: string | undefined;
|
||||
let shouldRevokeLocalPreview = false;
|
||||
let dimensions: { width?: number; height?: number } = {};
|
||||
try {
|
||||
dimensions = await readImageDimensions(localPreviewUrl);
|
||||
} catch {
|
||||
dimensions = {};
|
||||
}
|
||||
|
||||
const mimeType = normalizeEcommerceImageMime(file.type);
|
||||
try {
|
||||
const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType });
|
||||
const uploaded = await aiGenerationClient.uploadAssetBinary(uploadBlob, {
|
||||
name: file.name,
|
||||
mimeType,
|
||||
scope: ecommerceOssScopes.productSource,
|
||||
});
|
||||
src = uploaded.url;
|
||||
ossKey = uploaded.ossKey;
|
||||
shouldRevokeLocalPreview = true;
|
||||
} catch {
|
||||
src = localPreviewUrl;
|
||||
} finally {
|
||||
if (shouldRevokeLocalPreview) URL.revokeObjectURL(localPreviewUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${prefix}-${stamp}-${index}`,
|
||||
src,
|
||||
name: file.name,
|
||||
file,
|
||||
format: getImageFileFormat(file),
|
||||
mimeType,
|
||||
ossKey,
|
||||
...dimensions,
|
||||
};
|
||||
}));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise<string> {
|
||||
if (!sourceUrl) return sourceUrl;
|
||||
try {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const { url } = await aiGenerationClient.uploadAsset({
|
||||
dataUrl: sourceUrl,
|
||||
name: `${namePrefix}-${Date.now()}.png`,
|
||||
scope,
|
||||
});
|
||||
return url || sourceUrl;
|
||||
}
|
||||
|
||||
if (sourceUrl.startsWith("blob:")) {
|
||||
const rawBlob = await fetch(sourceUrl).then((res) => res.blob());
|
||||
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const { url } = await aiGenerationClient.uploadAssetBinary(blob, {
|
||||
name: `${namePrefix}-${Date.now()}.png`,
|
||||
mimeType,
|
||||
scope,
|
||||
});
|
||||
return url;
|
||||
}
|
||||
|
||||
const { url } = await aiGenerationClient.uploadAssetByUrl({
|
||||
sourceUrl,
|
||||
name: `${namePrefix}-${Date.now()}`,
|
||||
scope,
|
||||
});
|
||||
return url || sourceUrl;
|
||||
} catch {
|
||||
return sourceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyRejectedImages(files: File[]): File[] {
|
||||
const { accepted, rejected } = validateEcommerceImageFiles(files);
|
||||
const message = summarizeRejectedImages(rejected);
|
||||
if (message) toast.error(message);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
export {
|
||||
createLocalImageItems,
|
||||
uploadImageItem,
|
||||
createUploadedImageItems,
|
||||
persistGeneratedImageUrl,
|
||||
notifyRejectedImages,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import type { CommerceDefaultIntent } from "./ecommerceTypes";
|
||||
import { defaultCommerceIntentFallback, normalizeDefaultCommerceIntent } from "./ecommerceConstants";
|
||||
|
||||
/**
|
||||
* 电商创意意图分类器,从 EcommercePage.tsx 抽出。
|
||||
* 调用 aiGenerationClient.chatCompletion 做意图判定,失败时回退到默认意图。
|
||||
*/
|
||||
|
||||
const classifyDefaultCommerceIntent = async (input: {
|
||||
prompt: string;
|
||||
referenceCount: number;
|
||||
ratio: string;
|
||||
language: string;
|
||||
platform: string;
|
||||
}): Promise<CommerceDefaultIntent> => {
|
||||
const content = [
|
||||
"Classify this ecommerce creative request. Return only compact JSON.",
|
||||
'Schema: {"kind":"image"|"video","scenario":"poster"|"mainImage"|"scene"|"festival"|"model"|"background"|"retouch"|"salesVideo"}.',
|
||||
"Use salesVideo for video, short-video, UGC, storyboard, or product-demo motion requests.",
|
||||
"Use background for changing/replacing a product image background.",
|
||||
"Use retouch for inpainting, cleanup, seamless edit, repair, or localized image modification.",
|
||||
"Use model for try-on, human model, wearable, or mannequin requests.",
|
||||
"Use poster for campaign posters, sale posters, banners, or marketing layouts.",
|
||||
"Use scene for lifestyle/usage environment images.",
|
||||
"Use festival for holiday/seasonal style images.",
|
||||
"Use mainImage for product hero/main image requests or unclear image requests.",
|
||||
`Prompt: ${input.prompt || "(empty)"}`,
|
||||
`Reference image count: ${input.referenceCount}`,
|
||||
`Platform: ${input.platform}`,
|
||||
`Ratio: ${input.ratio}`,
|
||||
`Language: ${input.language}`,
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const text = await aiGenerationClient.chatCompletion({
|
||||
messages: [
|
||||
{ role: "system", content: "You are a strict ecommerce creative intent classifier. Respond with JSON only." },
|
||||
{ role: "user", content },
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0,
|
||||
});
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
return normalizeDefaultCommerceIntent(JSON.parse(jsonMatch?.[0] || text));
|
||||
} catch {
|
||||
return defaultCommerceIntentFallback;
|
||||
}
|
||||
};
|
||||
|
||||
export { classifyDefaultCommerceIntent };
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AppstoreOutlined, FileImageOutlined, LayoutOutlined, SkinOutlined, VideoCameraOutlined } from "@ant-design/icons";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ProductSetOutputKey } from "./utils/platformRules";
|
||||
import type { CommerceScenarioKey, ProductKitToolKey } from "./ecommerceTypes";
|
||||
import { getPlatformLogoMarks, getPlatformLogoVariant } from "./ecommerceConstants";
|
||||
|
||||
/**
|
||||
* 含 JSX 的模块级常量,从 EcommercePage.tsx 抽出。
|
||||
* 与 ecommerceConstants.ts 分离,因这些常量返回 ReactNode,需 .tsx 扩展。
|
||||
*/
|
||||
|
||||
const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [
|
||||
{ key: "set", label: "商品套图", icon: <AppstoreOutlined /> },
|
||||
{ key: "detail", label: "A+详情", icon: <FileImageOutlined /> },
|
||||
{ key: "wear", label: "服饰穿搭", icon: <SkinOutlined /> },
|
||||
{ key: "clone", label: "电商AI作图", icon: <AppstoreOutlined /> },
|
||||
];
|
||||
|
||||
const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [
|
||||
{ key: "set", label: "套图", desc: "主图/卖点/场景", icon: <AppstoreOutlined /> },
|
||||
{ key: "detail", label: "详情图", desc: "长图模块化生成", icon: <LayoutOutlined /> },
|
||||
{ key: "model", label: "模特图", desc: "真人穿搭展示", icon: <SkinOutlined /> },
|
||||
{ key: "video", label: "短视频", desc: "分镜视频链路", icon: <VideoCameraOutlined /> },
|
||||
];
|
||||
const cloneOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [
|
||||
...productSetOutputOptions,
|
||||
];
|
||||
const commerceScenarioOptions: Array<{ key: CommerceScenarioKey; label: string; desc: string; icon: ReactNode }> = [
|
||||
{ key: "popular", label: "热门", desc: "高频模板", icon: <span role="img" aria-label="fire">🔥</span> },
|
||||
{ key: "poster", label: "海报生成", desc: "活动视觉", icon: <span role="img" aria-label="poster">🎨</span> },
|
||||
{ key: "mainImage", label: "商品主图", desc: "主图转化", icon: <span role="img" aria-label="product">🛍️</span> },
|
||||
{ key: "model", label: "模特图", desc: "真人展示", icon: <span role="img" aria-label="model">🕴️</span> },
|
||||
{ key: "scene", label: "场景图", desc: "生活氛围", icon: <span role="img" aria-label="scene">🌅</span> },
|
||||
{ key: "festival", label: "节日风格图", desc: "节点营销", icon: <span role="img" aria-label="festival">🎉</span> },
|
||||
{ key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: <span role="img" aria-label="video">🎬</span> },
|
||||
{ key: "background", label: "更换背景", desc: "背景重构", icon: <span role="img" aria-label="background">✨</span> },
|
||||
{ key: "retouch", label: "无痕改图", desc: "精修优化", icon: <span role="img" aria-label="retouch">🪄</span> },
|
||||
];
|
||||
|
||||
const renderPlatformLogo = (value: string) => {
|
||||
const marks = getPlatformLogoMarks(value);
|
||||
const variant = getPlatformLogoVariant(value);
|
||||
return (
|
||||
<span
|
||||
className={`ecom-platform-logo-mark ecom-platform-logo-mark--${variant}${marks.length > 1 ? " ecom-platform-logo-mark--duo" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{marks.map((text) => (
|
||||
<span key={text} className={`ecom-platform-logo-mark__tile${text.length > 1 ? " ecom-platform-logo-mark__tile--wide" : ""}`}>
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export { sideTools, productSetOutputOptions, cloneOutputOptions, commerceScenarioOptions, renderPlatformLogo };
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { CloneResult } from "./utils/clonePersistence";
|
||||
import type { ProductSetOutputKey } from "./utils/platformRules";
|
||||
|
||||
/**
|
||||
* 模块级类型与接口,从 EcommercePage.tsx 抽出。
|
||||
* 这些类型原为文件私有(EcommercePage 仅 default 导出),现集中于此供页面与新拆分文件共享。
|
||||
*/
|
||||
|
||||
type SmartCutoutImageItem = { src: string; name: string; originalSrc?: string };
|
||||
|
||||
interface ProductClonePageProps {
|
||||
onWorkspaceChromeChange?: (state: { isToolPage: boolean }) => void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
||||
type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo";
|
||||
type CommerceDefaultImageScenarioKey = Exclude<CommerceScenarioKey, "popular" | "salesVideo">;
|
||||
type CommerceDefaultIntent =
|
||||
| { kind: "image"; scenario: CommerceDefaultImageScenarioKey }
|
||||
| { kind: "video"; scenario: "salesVideo" };
|
||||
type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
||||
type ProductKitToolKey = "set" | "detail" | "wear" | "clone";
|
||||
type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings" | "assetLibrary" | "workMode" | "aiWrite";
|
||||
type ComposerAssetTabKey = "recent" | "recipe" | "model";
|
||||
type ComposerWorkModeKey = "quick" | "think";
|
||||
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
|
||||
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
|
||||
type CloneTemplateAsset = {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
mediaUrl: string;
|
||||
mediaType?: "image" | "video";
|
||||
sourceAssets?: Array<{
|
||||
url: string;
|
||||
name: string;
|
||||
ossKey?: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
};
|
||||
interface CommerceScenarioTemplate extends CloneTemplateAsset {
|
||||
scenario: Exclude<CommerceScenarioKey, "popular">;
|
||||
output: ProductSetOutputKey;
|
||||
desc: string;
|
||||
badge: string;
|
||||
}
|
||||
type TryOnModelSource = "ai" | "library";
|
||||
type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed";
|
||||
type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
||||
|
||||
interface CanvasNode {
|
||||
id: string;
|
||||
mode: string;
|
||||
sourceImage?: string;
|
||||
results: CloneResult[];
|
||||
createdAt: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface PreviewTouchPoint {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface PreviewTouchGesture {
|
||||
mode: "none" | "pan" | "pinch";
|
||||
points: PreviewTouchPoint[];
|
||||
startOffset: { x: number; y: number };
|
||||
startZoom: number;
|
||||
startDistance: number;
|
||||
startCenter: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface EcommerceImagePromptOptions {
|
||||
gender?: string;
|
||||
age?: string;
|
||||
ethnicity?: string;
|
||||
body?: string;
|
||||
appearance?: string;
|
||||
scenes?: string[];
|
||||
customScene?: string;
|
||||
smartScene?: boolean;
|
||||
detailModules?: string[];
|
||||
}
|
||||
|
||||
export type {
|
||||
SmartCutoutImageItem,
|
||||
ProductClonePageProps,
|
||||
ProductCloneStatus,
|
||||
CommerceScenarioKey,
|
||||
CommerceDefaultImageScenarioKey,
|
||||
CommerceDefaultIntent,
|
||||
ProductSetStatus,
|
||||
ProductKitToolKey,
|
||||
ComposerMenuKey,
|
||||
ComposerAssetTabKey,
|
||||
ComposerWorkModeKey,
|
||||
CloneBasicSelectKey,
|
||||
CloneModelSelectKey,
|
||||
CloneTemplateAsset,
|
||||
CommerceScenarioTemplate,
|
||||
TryOnModelSource,
|
||||
TryOnStatus,
|
||||
DetailStatus,
|
||||
CanvasNode,
|
||||
PreviewTouchPoint,
|
||||
PreviewTouchGesture,
|
||||
EcommerceImagePromptOptions,
|
||||
};
|
||||
@@ -113,8 +113,31 @@ export interface EcommerceHistoryRecord {
|
||||
}
|
||||
|
||||
export const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
|
||||
// 历史记录的存储前缀 + 元数据标记。真实读写按用户分桶(见 getEcommerceHistoryStorageKey),
|
||||
// 此常量本身仍作为 metadata.localHistoryStorageKey 的稳定标记值,不能改动其值。
|
||||
export const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records";
|
||||
|
||||
// 当前登录用户的分桶标识:未登录返回 "anon",避免登出/换账号读到上一个用户的历史。
|
||||
// 与 useGenerationStore 的 hashUserId 保持一致的隔离策略。
|
||||
export function getEcommerceHistoryUserBucket(): string {
|
||||
if (typeof window === "undefined") return "anon";
|
||||
try {
|
||||
const raw = window.localStorage.getItem("omniai-web-session");
|
||||
if (!raw) return "anon";
|
||||
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
||||
const id = parsed?.user?.id;
|
||||
return id === undefined || id === null || id === "" ? "anon" : String(id);
|
||||
} catch {
|
||||
return "anon";
|
||||
}
|
||||
}
|
||||
|
||||
// 历史记录按用户分桶的实际 localStorage key。前缀仍是 omniai.ecommerce.,
|
||||
// 因此登出时 clearAllUserStorage 的前缀清理依旧覆盖到这些 key。
|
||||
export function getEcommerceHistoryStorageKey(): string {
|
||||
return `${ecommerceHistoryStorageKey}:${getEcommerceHistoryUserBucket()}`;
|
||||
}
|
||||
|
||||
export const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
|
||||
selling: 3,
|
||||
white: 1,
|
||||
@@ -296,7 +319,7 @@ export function clearCloneLatestSetting(): void {
|
||||
export function readEcommerceHistoryRecords(): EcommerceHistoryRecord[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey);
|
||||
const rawValue = window.localStorage.getItem(getEcommerceHistoryStorageKey());
|
||||
if (!rawValue) return [];
|
||||
const parsedValue: unknown = JSON.parse(rawValue);
|
||||
if (!Array.isArray(parsedValue)) return [];
|
||||
@@ -313,7 +336,7 @@ export function readEcommerceHistoryRecords(): EcommerceHistoryRecord[] {
|
||||
export function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(
|
||||
ecommerceHistoryStorageKey,
|
||||
getEcommerceHistoryStorageKey(),
|
||||
JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user