13 Commits

Author SHA1 Message Date
Codex 86e0f83f73 fix(ecommerce): define missing selectAnchorRef in one-click video panel 2026-06-17 14:28:45 +08:00
Codex 2bc6fb7ab1 feat(ecommerce): add one-click video quick tool page
- Add '一键视频' button left of '更多功能' in quick action board

- Create EcommerceOneClickVideoPanel with hot-clone-like UI

- Reuse EcommerceVideoWorkspace on the right for video flow

- Add light-theme CSS matching quick-set/hot-clone pages
2026-06-17 14:25:18 +08:00
Codex 65be92ba43 fix(ecommerce): strengthen product set count stepper theme override
Use html body #root .ecommerce-standalone prefix and !important

to ensure the stepper matches the local light theme.
2026-06-17 11:56:30 +08:00
Codex 98acb79a20 fix(ecommerce): align product set count stepper with local light theme
Add local-theme-parity overrides for .clone-ai-count-stepper

container and count value so they match the page's light palette.
2026-06-17 11:51:14 +08:00
Codex d819cecfc6 fix(ecommerce): restore quick action colors for product/copywriting/more
Add missing --quick-accent/--quick-bg/--quick-text variables for

- product (商品套图)

- copywriting (一键文案)

- more (更多功能)
2026-06-17 11:33:36 +08:00
Codex 2c3c6eb2c9 Merge remote-tracking branch 'origin/main' into codex/main-latest-20260615-030000
# Conflicts:
#	src/styles/ecommerce-standalone.css
2026-06-17 11:04:26 +08:00
stringadmin d83ad25be3 Merge pull request 'feat: enhance scenario tabs with more/expand toggle, template carousel navigation, and 16 new templates' (#23) from feat/ecommerce-scenario-tabs into main
Reviewed-on: #23
2026-06-17 02:16:55 +00:00
ludan e86cd18f1d feat: enhance scenario tabs with more/expand toggle, template carousel navigation, and 16 new templates
- package.json: Add @phosphor-icons/react ^2.1.10 dependency for additional icon set
- package-lock.json: Sync lockfile with new dependency and clean peer:true markers

- EcommercePage.tsx:
  - Reorder scenario tabs: model (模特图) moved to position 3 after mainImage
  - Add primaryCommerceScenarioKeys to define first 4 visible scenarios (popular/poster/mainImage/model)
  - Change activeCommerceScenario initial state from "popular" to null — no scenario auto-selected on mount
  - Add isCommerceScenarioMoreOpen state to toggle expanded scenario list
  - Add templateStripRef for programmatic scroll control
  - visibleCommerceScenarioOptions: filter to primary keys by default, show all when expanded
  - "更多/收起" toggle button with dashed border, dynamic icon (··· or CloseOutlined)
  - isCloneTemplateStripVisible defaults to false — template strip hidden until scenario clicked
  - activeCommerceScenarioTemplates: returns empty array when no scenario selected
  - Template strip wrapped in ecom-command-template-carousel with prev/next navigation arrows (‹ ›)
  - scrollCommerceTemplateStrip(direction): smooth scroll by card width or viewport step
  - handleCommerceScenarioMoreToggle: expand/collapse scenario list
  - handleCloneTemplateCardClick: now also sets activeCommerceScenario to card.scenario
  - Scroll hint text: "点击更多查看全部场景" when collapsed, "左右滑动查看全部场景" when expanded
  - Auto-scroll template strip to left on scenario/visibility change via useEffect

  - Add 16 new CommerceScenarioTemplate cards:
    - poster: 节日礼赠海报, 奢品香水海报
    - mainImage: 模特展示主图, 细节质感主图
    - model: 男装夹克模特, 帽子配饰模特
    - scene: 户外露营场景, 美妆喷雾场景
    - festival: 父亲节礼盒图, 香薰蜡烛礼盒
    - background: 高级灰背景, 居家背景
    - retouch: 色彩统一精修, 细节锐化精修
    - salesVideo: 痛点种草视频, 温馨开箱视频

- ecommerce-standalone.css (+559 lines):
  - Scenario shell (.ecom-command-scenario-shell): centered flex wrapper with padding
  - Scenario tabs: pill-shaped buttons (border-radius 999px), gradient backgrounds, scroll-snap
  - "更多" button: dashed border in collapsed state, solid when expanded
  - Template carousel (.ecom-command-template-carousel): horizontal scroll with snap, smooth scroll-behavior, hidden scrollbar
  - Carousel fade edges: ::before/::after gradient masks (54px width)
  - Navigation arrows (.ecom-command-template-nav): circular buttons positioned absolute at edges, hover/focus reveal with scale transition, opacity 0→1 on carousel hover
  - Template cards: flexible sizing (clamp 260px-312px), 96px media thumbnail, scroll-snap-align start
  - Per-scenario color mapping via --mode-accent: popular pink, poster/festival orange, mainImage/scene/background green, model/retouch/salesVideo blue, more blue
  - Active state: radial gradient glow + color-mix border/shadow from --mode-accent
  - Scroll hint: animated ← → arrows (ecom-scroll-hint-left/right keyframes at 1.6s infinite)
  - Responsive: ≤900px left-aligned tabs + persistent nav arrows, ≤640px compact sizing, full-width carousel with overflow hidden
2026-06-17 10:16:40 +08:00
Codex eb7b769155 Merge remote-tracking branch 'origin/main' into codex/main-latest-20260615-030000
# Conflicts:
#	src/styles/ecommerce-standalone.css
2026-06-16 23:28:07 +08:00
stringadmin 0e24ccf7b1 Merge pull request 'Main merge work' (#22) from main-merge-work into main
Reviewed-on: #22
2026-06-16 14:51:01 +00:00
stringadmin f8ccad52f9 Merge branch 'main' into main-merge-work 2026-06-16 14:50:51 +00:00
Codex ad38a4a0e3 feat(ecommerce): add one-click copywriting tool with quick-board entry
- Add EcommerceCopywritingPanel component

- Wire copywriting tool into EcommercePage routing and state

- Add quick action entry; place before '更多功能'

- Add copywriting styles aligned with quick-set/hot-clone pages

- Merge latest main
2026-06-16 21:47:07 +08:00
stringadmin 30222cd830 Merge pull request 'Main merge work' (#21) from main-merge-work into main
Reviewed-on: #21
2026-06-16 13:13:50 +00:00
7 changed files with 2713 additions and 113 deletions
+14 -8
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "5.3.0", "@ant-design/icons": "5.3.0",
"@phosphor-icons/react": "^2.1.10",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"scheduler": "0.23.0", "scheduler": "0.23.0",
@@ -119,7 +120,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -851,6 +851,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">= 16.8",
"react-dom": ">= 16.8"
}
},
"node_modules/@rollup/pluginutils": { "node_modules/@rollup/pluginutils": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
@@ -1296,7 +1309,6 @@
"integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==", "integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"@types/scheduler": "*", "@types/scheduler": "*",
@@ -1581,7 +1593,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@@ -2710,7 +2721,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -2723,7 +2733,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.0" "scheduler": "^0.23.0"
@@ -2794,7 +2803,6 @@
"integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
}, },
@@ -3166,7 +3174,6 @@
"integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==", "integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
"postcss": "^8.4.35", "postcss": "^8.4.35",
@@ -3257,7 +3264,6 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.6.1", "@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1", "@vitest/runner": "1.6.1",
+1
View File
@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "5.3.0", "@ant-design/icons": "5.3.0",
"@phosphor-icons/react": "^2.1.10",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"scheduler": "0.23.0", "scheduler": "0.23.0",
+759 -58
View File
@@ -25,6 +25,17 @@ import {
TableOutlined, TableOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import {
ArrowsCounterClockwise,
Fire,
FrameCorners,
Gift,
MagicWand,
Mountains,
ShoppingBag,
User,
VideoCamera,
} from "@phosphor-icons/react";
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTypewriter } from "../../hooks/useTypewriter"; import { useTypewriter } from "../../hooks/useTypewriter";
@@ -39,6 +50,8 @@ import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
import EcommerceSetPanel from "./panels/EcommerceSetPanel"; import EcommerceSetPanel from "./panels/EcommerceSetPanel";
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
import EcommerceClonePanel from "./panels/EcommerceClonePanel"; import EcommerceClonePanel from "./panels/EcommerceClonePanel";
import EcommerceCopywritingPanel from "./panels/EcommerceCopywritingPanel";
import EcommerceOneClickVideoPanel from "./panels/EcommerceOneClickVideoPanel";
import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
import { downloadResultAsset } from "../workbench/workbenchDownload"; import { downloadResultAsset } from "../workbench/workbenchDownload";
import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules"; import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules";
@@ -1044,16 +1057,17 @@ const cloneOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc:
...productSetOutputOptions, ...productSetOutputOptions,
]; ];
const commerceScenarioOptions: Array<{ key: CommerceScenarioKey; label: string; desc: string; icon: ReactNode }> = [ const commerceScenarioOptions: Array<{ key: CommerceScenarioKey; label: string; desc: string; icon: ReactNode }> = [
{ key: "popular", label: "热门", desc: "高频模板", icon: <FireOutlined /> }, { key: "popular", label: "热门", desc: "高频模板", icon: <span role="img" aria-label="fire">🔥</span> },
{ key: "poster", label: "海报生成", desc: "活动视觉", icon: <LayoutOutlined /> }, { key: "poster", label: "海报生成", desc: "活动视觉", icon: <span role="img" aria-label="poster">🎨</span> },
{ key: "mainImage", label: "商品主图", desc: "主图转化", icon: <FileImageOutlined /> }, { key: "mainImage", label: "商品主图", desc: "主图转化", icon: <span role="img" aria-label="product">🛍</span> },
{ key: "scene", label: "场景图", desc: "生活氛围", icon: <AppstoreOutlined /> }, { key: "model", label: "模特图", desc: "真人展示", icon: <span role="img" aria-label="model">🕴</span> },
{ key: "festival", label: "节日风格图", desc: "节点营销", icon: <GlobalOutlined /> }, { key: "scene", label: "场景图", desc: "生活氛围", icon: <span role="img" aria-label="scene">🌅</span> },
{ key: "model", label: "模特图", desc: "真人展示", icon: <SkinOutlined /> }, { key: "festival", label: "节日风格图", desc: "节点营销", icon: <span role="img" aria-label="festival">🎉</span> },
{ key: "background", label: "更换背景", desc: "背景重构", icon: <ClearOutlined /> }, { key: "background", label: "更换背景", desc: "背景重构", icon: <span role="img" aria-label="background"></span> },
{ key: "retouch", label: "无痕改图", desc: "精修优化", icon: <EditOutlined /> }, { key: "retouch", label: "无痕改图", desc: "精修优化", icon: <span role="img" aria-label="retouch">🪄</span> },
{ key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: <VideoCameraOutlined /> }, { key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: <span role="img" aria-label="video">🎬</span> },
]; ];
const primaryCommerceScenarioKeys: CommerceScenarioKey[] = ["popular", "poster", "mainImage", "model"];
const commerceScenarioOutputMap: Record<Exclude<CommerceScenarioKey, "popular">, ProductSetOutputKey> = { const commerceScenarioOutputMap: Record<Exclude<CommerceScenarioKey, "popular">, ProductSetOutputKey> = {
poster: "set", poster: "set",
mainImage: "set", mainImage: "set",
@@ -1225,6 +1239,166 @@ const commerceScenarioTemplates: CommerceScenarioTemplate[] = [
prompt: "生成商品使用演示短视频分镜,围绕使用过程、关键卖点和效果对比展开,节奏清晰,适合带货转化。", prompt: "生成商品使用演示短视频分镜,围绕使用过程、关键卖点和效果对比展开,节奏清晰,适合带货转化。",
mediaUrl: ossAssets.ecommerce.inspiration.asinListing, 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,
},
]; ];
const popularCommerceScenarioTemplates = commerceScenarioOptions const popularCommerceScenarioTemplates = commerceScenarioOptions
.filter((option): option is { key: Exclude<CommerceScenarioKey, "popular">; label: string; desc: string; icon: ReactNode } => option.key !== "popular") .filter((option): option is { key: Exclude<CommerceScenarioKey, "popular">; label: string; desc: string; icon: ReactNode } => option.key !== "popular")
@@ -1673,6 +1847,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const composerMenuCloseTimeoutRef = useRef<number | null>(null); const composerMenuCloseTimeoutRef = useRef<number | null>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null); const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const commandComposerWrapRef = useRef<HTMLElement | null>(null); const commandComposerWrapRef = useRef<HTMLElement | null>(null);
const templateStripRef = useRef<HTMLElement | null>(null);
const garmentInputRef = useRef<HTMLInputElement>(null); const garmentInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null); const detailInputRef = useRef<HTMLInputElement>(null);
const detailProgressRef = useRef<number | null>(null); const detailProgressRef = useRef<number | null>(null);
@@ -1711,7 +1886,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<ProductSetPreviewSelection | null>(null); const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<ProductSetPreviewSelection | null>(null);
const [showHostingModal, setShowHostingModal] = useState(false); const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]); const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | null>(null); const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | "quick-set" | "copywriting" | "oneClickVideo" | null>(null);
const [smartCutoutImage, setSmartCutoutImage] = useState<SmartCutoutImageItem | null>(null); const [smartCutoutImage, setSmartCutoutImage] = useState<SmartCutoutImageItem | null>(null);
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<SmartCutoutImageItem[]>([]); const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<SmartCutoutImageItem[]>([]);
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff"); const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
@@ -1747,9 +1922,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState<string | null>(null); const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState<string | null>(null);
const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0); const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [activeCommerceScenario, setActiveCommerceScenario] = useState<CommerceScenarioKey>("popular"); const [activeCommerceScenario, setActiveCommerceScenario] = useState<CommerceScenarioKey | null>(null);
const [isCommerceScenarioMoreOpen, setIsCommerceScenarioMoreOpen] = useState(false);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(defaultCloneOutput); const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(defaultCloneOutput);
const [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(true); const [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(false);
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false); const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false);
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0); const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
@@ -1785,6 +1961,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [cloneVideoSmart, setCloneVideoSmart] = useState(true); const [cloneVideoSmart, setCloneVideoSmart] = useState(true);
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
const [isCloneConversationCollapsed, setIsCloneConversationCollapsed] = useState(false); const [isCloneConversationCollapsed, setIsCloneConversationCollapsed] = useState(false);
const [quickSetStatus, setQuickSetStatus] = useState<"idle" | "generating" | "done" | "failed">("idle");
const [quickSetResultUrls, setQuickSetResultUrls] = useState<string[]>([]);
const [quickSetProgress, setQuickSetProgress] = useState(0);
const [quickSetRequirement, setQuickSetRequirement] = useState("");
const quickSetProgressRef = useRef<number | null>(null);
const [previewZoom, setPreviewZoom] = useState(1); const [previewZoom, setPreviewZoom] = useState(1);
const quickSetSelectTimerRef = useRef<number | null>(null); const quickSetSelectTimerRef = useRef<number | null>(null);
const openQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null); const openQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null);
@@ -2253,9 +2434,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const selectedProductSetOutput = const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!; productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!; const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const activeCommerceScenarioTemplates = activeCommerceScenario === "popular" const visibleCommerceScenarioOptions = useMemo(
? popularCommerceScenarioTemplates () =>
: commerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario); isCommerceScenarioMoreOpen
? commerceScenarioOptions
: commerceScenarioOptions.filter((option) => primaryCommerceScenarioKeys.includes(option.key)),
[isCommerceScenarioMoreOpen],
);
const activeCommerceScenarioTemplates = activeCommerceScenario === null
? []
: activeCommerceScenario === "popular"
? popularCommerceScenarioTemplates
: commerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario);
useEffect(() => {
templateStripRef.current?.scrollTo({ left: 0, behavior: "auto" });
}, [activeCommerceScenario, isCloneTemplateStripVisible]);
const cloneRequirementPlaceholder = const cloneRequirementPlaceholder =
cloneOutput === "model" cloneOutput === "model"
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描述(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数" ? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描述(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
@@ -2270,6 +2463,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling"; const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const canGenerateHot = cloneReferenceImages.length > 0 && hotStatus !== "generating"; const canGenerateHot = cloneReferenceImages.length > 0 && hotStatus !== "generating";
const canGenerateQuickSet = productImages.length > 0 && quickSetStatus !== "generating";
const cloneVideoDurationProgress = const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100; ((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle: CSSProperties = useMemo( const cloneVideoDurationStyle: CSSProperties = useMemo(
@@ -3663,6 +3857,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (mappedOutput !== cloneOutput) handleCloneOutputChange(mappedOutput); if (mappedOutput !== cloneOutput) handleCloneOutputChange(mappedOutput);
}; };
const handleCommerceScenarioMoreToggle = () => {
setIsCommerceScenarioMoreOpen((visible) => !visible);
setComposerMenu(null);
};
const scrollCommerceTemplateStrip = (direction: -1 | 1) => {
const strip = templateStripRef.current;
if (!strip) return;
const firstCard = strip.querySelector<HTMLElement>(".ecom-command-template-card");
const cardStep = firstCard ? firstCard.offsetWidth + 14 : 0;
const viewportStep = Math.max(280, strip.clientWidth * 0.78);
strip.scrollBy({
left: direction * Math.max(cardStep, viewportStep),
behavior: "smooth",
});
};
const handleCloneMarketChange = (nextMarket: string) => { const handleCloneMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket); const normalizedMarket = normalizeMarket(nextMarket);
setMarket(normalizedMarket); setMarket(normalizedMarket);
@@ -4607,6 +4818,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
); );
}; };
const handleQuickSetAiWrite = () => {
setQuickSetRequirement(
"1.产品名称:无线降噪蓝牙耳机\n2.核心卖点:主动降噪、24H续航、低延迟连接、舒适佩戴\n3.适用人群:通勤、办公、运动和旅行用户\n4.期望场景:地铁通勤、居家办公、户外运动\n5.具体参数:蓝牙5.3、IPX4防水、快充10分钟使用2小时",
);
};
const stopHotProgress = () => { const stopHotProgress = () => {
if (hotProgressRef.current !== null) { if (hotProgressRef.current !== null) {
window.clearInterval(hotProgressRef.current); window.clearInterval(hotProgressRef.current);
@@ -4614,6 +4831,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
} }
}; };
const stopQuickSetProgress = () => {
if (quickSetProgressRef.current !== null) {
window.clearInterval(quickSetProgressRef.current);
quickSetProgressRef.current = null;
}
};
const startQuickSetProgress = () => {
stopQuickSetProgress();
setQuickSetProgress(0);
quickSetProgressRef.current = window.setInterval(() => {
setQuickSetProgress((prev) => {
if (prev >= 90) {
stopQuickSetProgress();
return 90;
}
return prev + (90 - prev) * 0.06;
});
}, 500);
};
const startHotProgress = () => { const startHotProgress = () => {
stopHotProgress(); stopHotProgress();
setHotProgress(0); setHotProgress(0);
@@ -4651,6 +4889,42 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
); );
}; };
const handleQuickSetGenerate = () => {
if (!canGenerateQuickSet) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
startQuickSetProgress();
setQuickSetStatus("generating");
void generateSetImages(
productImages, cloneSetCounts, quickSetRequirement,
platform, ratio, language, market,
(s) => {
setQuickSetStatus(s as ProductCloneStatus);
if (s === "done") {
stopQuickSetProgress();
setQuickSetProgress(100);
} else if (s === "failed") {
stopQuickSetProgress();
setQuickSetProgress(0);
}
},
(urls) => {
setQuickSetResultUrls(urls);
const validUrls = urls.filter(Boolean);
if (validUrls.length) {
setQuickSetStatus("done");
stopQuickSetProgress();
setQuickSetProgress(100);
} else {
setQuickSetStatus("failed");
stopQuickSetProgress();
setQuickSetProgress(0);
}
},
);
lastFailedActionRef.current = () => handleQuickSetGenerate();
};
const handleHotMaterialMouseEnter = (src: string, event: ReactMouseEvent<HTMLElement>) => { const handleHotMaterialMouseEnter = (src: string, event: ReactMouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
const previewHalfWidth = 150; const previewHalfWidth = 150;
@@ -4715,6 +4989,57 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setComposerMenu(null); setComposerMenu(null);
}; };
const openQuickSetPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("quick-set");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsQuickPanelCollapsed(false);
};
const closeQuickSetPage = () => {
stopQuickSetProgress();
setActiveQuickTool(null);
setQuickSetStatus("idle");
setQuickSetResultUrls([]);
setQuickSetProgress(0);
setQuickSetRequirement("");
setComposerMenu(null);
};
const openCopywritingPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("copywriting");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsQuickPanelCollapsed(false);
};
const closeCopywritingPage = () => {
setActiveQuickTool(null);
setComposerMenu(null);
};
const openOneClickVideoPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("oneClickVideo");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsQuickPanelCollapsed(false);
};
const closeOneClickVideoPage = () => {
setActiveQuickTool(null);
setComposerMenu(null);
};
const handleOneClickVideoPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setPlatform(normalizedPlatform);
setRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, "video"));
setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market));
};
const resetTask = () => { const resetTask = () => {
setSetImages([]); setSetImages([]);
setProductSetRequirement(""); setProductSetRequirement("");
@@ -4778,6 +5103,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const isImageEditTool = isCloneTool && activeQuickTool === "image-edit"; const isImageEditTool = isCloneTool && activeQuickTool === "image-edit";
const isTranslateTool = isCloneTool && activeQuickTool === "translate"; const isTranslateTool = isCloneTool && activeQuickTool === "translate";
const isHotCloneTool = isCloneTool && activeQuickTool === "hot"; const isHotCloneTool = isCloneTool && activeQuickTool === "hot";
const isQuickSetTool = isCloneTool && activeQuickTool === "quick-set";
const isCopywritingTool = isCloneTool && activeQuickTool === "copywriting";
const isOneClickVideoTool = isCloneTool && activeQuickTool === "oneClickVideo";
const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具"; const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具";
const setPrimaryLabel = const setPrimaryLabel =
setImages.length === 0 setImages.length === 0
@@ -5203,6 +5531,19 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(hotRatio), options: quickSetRatioOptions, onChange: setHotRatio }, { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(hotRatio), options: quickSetRatioOptions, onChange: setHotRatio },
]; ];
const quickSetBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: platform, options: platformOptions, onChange: setPlatform },
{ key: "market", label: "国家", value: market, options: marketOptions, onChange: setMarket },
{ key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(ratio), options: quickSetRatioOptions, onChange: setRatio },
];
const cloneModelSelects: Array<{ const cloneModelSelects: Array<{
key: CloneModelSelectKey; key: CloneModelSelectKey;
label: string; label: string;
@@ -5745,6 +6086,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}; };
const handleCloneTemplateCardClick = (card: CommerceScenarioTemplate) => { const handleCloneTemplateCardClick = (card: CommerceScenarioTemplate) => {
setActiveCommerceScenario(card.scenario);
if (card.output !== cloneOutput) handleCloneOutputChange(card.output); if (card.output !== cloneOutput) handleCloneOutputChange(card.output);
setIsCloneTemplateStripVisible(true); setIsCloneTemplateStripVisible(true);
setComposerMenu(null); setComposerMenu(null);
@@ -6084,23 +6426,38 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onChange={handleSmartCutoutUpload} onChange={handleSmartCutoutUpload}
aria-label="上传智能抠图素材" aria-label="上传智能抠图素材"
/> />
<div className="ecom-command-mode-tabs ecom-command-scenario-tabs" aria-label="电商场景"> <div className="ecom-command-scenario-shell" data-expanded={isCommerceScenarioMoreOpen ? "true" : "false"}>
{commerceScenarioOptions.map((option) => ( <div className="ecom-command-mode-tabs ecom-command-scenario-tabs" aria-label="电商场景">
{visibleCommerceScenarioOptions.map((option) => (
<button
key={option.key}
type="button"
className={`${activeCommerceScenario === option.key ? "is-active" : ""}${activeCommerceScenario === option.key && isCloneTemplateStripVisible ? " is-open" : ""}`}
onClick={() => handleCommerceScenarioClick(option.key)}
>
<span className={`ecom-command-mode-icon ecom-command-mode-icon--${option.key}`} aria-hidden="true">{option.icon}</span>
<strong>{option.label}</strong>
{activeCommerceScenario === option.key && isCloneTemplateStripVisible ? (
<span className="ecom-command-scenario-close" aria-hidden="true"><CloseOutlined /></span>
) : null}
</button>
))}
<button <button
key={option.key}
type="button" type="button"
className={`${activeCommerceScenario === option.key ? "is-active" : ""}${activeCommerceScenario === option.key && isCloneTemplateStripVisible ? " is-open" : ""}`} className={`ecom-command-scenario-more${isCommerceScenarioMoreOpen ? " is-open" : ""}`}
onClick={() => handleCommerceScenarioClick(option.key)} onClick={handleCommerceScenarioMoreToggle}
aria-expanded={isCommerceScenarioMoreOpen}
> >
<span className={`ecom-command-mode-icon ecom-command-mode-icon--${option.key}`} aria-hidden="true">{option.icon}</span> <span className="ecom-command-mode-icon ecom-command-mode-icon--more" aria-hidden="true">
<strong>{option.label}</strong> {isCommerceScenarioMoreOpen ? <CloseOutlined /> : "···"}
{activeCommerceScenario === option.key && isCloneTemplateStripVisible ? ( </span>
<span className="ecom-command-scenario-close" aria-hidden="true"><CloseOutlined /></span> <strong>{isCommerceScenarioMoreOpen ? "收起" : "更多"}</strong>
) : null}
</button> </button>
))} </div>
</div> </div>
<span className="ecom-command-scenario-scroll-hint" aria-hidden="true"></span> <span className="ecom-command-scenario-scroll-hint" aria-hidden="true">
{isCommerceScenarioMoreOpen ? "左右滑动查看全部场景" : "点击更多查看全部场景"}
</span>
<div className="clone-ai-input-wrapper ecom-command-composer"> <div className="clone-ai-input-wrapper ecom-command-composer">
{productImages.length ? ( {productImages.length ? (
<div className="ecom-command-asset-popover" aria-label={`已上传素材,${productImages.length}/${maxCloneProductImages}`}> <div className="ecom-command-asset-popover" aria-label={`已上传素材,${productImages.length}/${maxCloneProductImages}`}>
@@ -6193,31 +6550,53 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div> </div>
{renderComposerMenu()} {renderComposerMenu()}
</div> </div>
{(status === "idle" || status === "ready") && !showMainVideoWorkspace && isCloneTemplateStripVisible ? ( {(status === "idle" || status === "ready") && !showMainVideoWorkspace && activeCommerceScenario !== null && isCloneTemplateStripVisible ? (
<section className={`ecom-command-template-strip ecom-command-template-strip--${activeCommerceScenario}`} aria-label="模板卡片"> <div className={`ecom-command-template-carousel ecom-command-template-carousel--${activeCommerceScenario}`}>
{activeCommerceScenarioTemplates.map((card) => ( <button
<button type="button"
key={card.id} className="ecom-command-template-nav ecom-command-template-nav--prev"
type="button" onClick={() => scrollCommerceTemplateStrip(-1)}
className="ecom-command-template-card" aria-label="查看上一组模板"
aria-label={card.title} >
onClick={(event) => {
event.preventDefault(); </button>
event.stopPropagation(); <section
handleCloneTemplateCardClick(card); ref={templateStripRef}
}} className={`ecom-command-template-strip ecom-command-template-strip--${activeCommerceScenario}`}
> aria-label="模板卡片"
<span className="ecom-command-template-card__media" aria-hidden="true"> >
<img src={card.mediaUrl} alt="" loading="lazy" /> {activeCommerceScenarioTemplates.map((card) => (
</span> <button
<span className="ecom-command-template-card__body"> key={card.id}
<span className="ecom-command-template-card__badge">{card.badge}</span> type="button"
<strong>{card.title}</strong> className="ecom-command-template-card"
<em>{card.desc}</em> aria-label={card.title}
</span> onClick={(event) => {
</button> event.preventDefault();
))} event.stopPropagation();
</section> handleCloneTemplateCardClick(card);
}}
>
<span className="ecom-command-template-card__media" aria-hidden="true">
<img src={card.mediaUrl} alt="" loading="lazy" />
</span>
<span className="ecom-command-template-card__body">
<span className="ecom-command-template-card__badge">{card.badge}</span>
<strong>{card.title}</strong>
<em>{card.desc}</em>
</span>
</button>
))}
</section>
<button
type="button"
className="ecom-command-template-nav ecom-command-template-nav--next"
onClick={() => scrollCommerceTemplateStrip(1)}
aria-label="查看下一组模板"
>
</button>
</div>
) : null} ) : null}
{(status === "idle" || status === "ready") && !showMainVideoWorkspace ? ( {(status === "idle" || status === "ready") && !showMainVideoWorkspace ? (
<section className="ecom-command-quick-board" aria-label="快捷功能"> <section className="ecom-command-quick-board" aria-label="快捷功能">
@@ -6228,15 +6607,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{ label: "智能抠图", tone: "cutout", icon: <ScissorOutlined />, onClick: openSmartCutoutUpload }, { label: "智能抠图", tone: "cutout", icon: <ScissorOutlined />, onClick: openSmartCutoutUpload },
{ label: "去除水印", tone: "watermark", icon: <ClearOutlined />, onClick: openWatermarkRemovalPage }, { label: "去除水印", tone: "watermark", icon: <ClearOutlined />, onClick: openWatermarkRemovalPage },
{ label: "图片翻译", tone: "translate", icon: <GlobalOutlined />, onClick: openImageTranslatePage }, { label: "图片翻译", tone: "translate", icon: <GlobalOutlined />, onClick: openImageTranslatePage },
{ label: "商品套图", tone: "product", icon: <AppstoreOutlined />, onClick: openQuickSetPage },
{ label: "一键文案", tone: "copywriting", icon: <EditOutlined />, onClick: openCopywritingPage },
{ label: "一键视频", tone: "video", icon: <VideoCameraOutlined />, onClick: openOneClickVideoPage },
{ label: "更多功能", tone: "more", icon: <SettingOutlined />, disabled: true },
].map((item) => ( ].map((item) => (
<button <button
key={item.label} key={item.label}
type="button" type="button"
className={`ecom-command-quick-card ecom-command-quick-card--${item.tone}`} className={`ecom-command-quick-card ecom-command-quick-card--${item.tone}`}
disabled={item.disabled}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
item.onClick?.(); if (item.disabled) {
toast.info("更多功能即将上线,敬请期待!");
} else {
item.onClick?.();
}
}} }}
> >
<span aria-hidden="true">{item.icon}</span> <span aria-hidden="true">{item.icon}</span>
@@ -7242,6 +7630,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const quickDetailVisibleSelect = quickDetailBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null; const quickDetailVisibleSelect = quickDetailBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickHotVisibleSelect = quickHotBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null; const quickHotVisibleSelect = quickHotBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickSetVisibleSelect = quickSetBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickDetailPreview = ( const quickDetailPreview = (
<main key="quick-detail" className={`ecom-quick-set-page ecom-quick-detail-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="A+详情页生成"> <main key="quick-detail" className={`ecom-quick-set-page ecom-quick-detail-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="A+详情页生成">
@@ -7703,6 +8092,266 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</main> </main>
); );
const quickSetGenPreview = (
<main key="quick-set" className={`ecom-quick-set-page ecom-quick-hot-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="电商套图生成">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="电商套图设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeQuickSetPage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeQuickSetPage}></button>
</header>
<section>
<strong><FileImageOutlined /> </strong>
{productImages.length ? (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material has-images${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsProductUploadDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsProductUploadDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
{renderQuickUploadThumbs(productImages, removeProductImage)}
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="添加更多素材"
onClick={(event) => {
event.stopPropagation();
productInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
</div>
) : (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsProductUploadDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsProductUploadDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
<FileImageOutlined />
<span></span>
<em> 7 </em>
<b>+ </b>
</div>
)}
<input
ref={productInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleProductUpload}
aria-label="上传商品图片"
/>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickSetBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em></em>
</button>
))}
</div>
{quickSetVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickSetVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickSetVisibleSelect.label}
>
{quickSetVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickSetVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickSetVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
) : null}
</div>
</section>
<section className="ecom-quick-set-count-section">
<span className="ecom-quick-set-label"></span>
<p className="ecom-quick-set-hint"> 1-16 </p>
<div className="ecom-quick-set-counts">
{cloneSetCountOptions.map((item) => {
const count = cloneSetCounts[item.key];
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
return (
<div key={item.key} className="ecom-quick-set-count-row">
<div className="ecom-quick-set-count-info">
<strong>{item.title}</strong>
<span>{item.desc}</span>
</div>
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
<button
type="button"
disabled={decrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, -1, decrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`减少${item.title}`}
>
-
</button>
<b>{count}</b>
<button
type="button"
disabled={incrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, 1, incrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`增加${item.title}`}
>
+
</button>
</div>
</div>
);
})}
</div>
</section>
<section className="ecom-quick-hot-requirement">
<div className="ecom-quick-hot-requirement__head">
<strong> &amp; </strong>
<button type="button" className="ecom-quick-hot-requirement__ai" onClick={handleQuickSetAiWrite}>AI </button>
</div>
<div className="ecom-quick-hot-requirement__input">
<textarea
value={quickSetRequirement}
onChange={(event) => setQuickSetRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息:产品名称、核心卖点、期望场景、具体参数"
maxLength={500}
/>
<span>{quickSetRequirement.length}/500</span>
</div>
</section>
<div className="ecom-quick-hot-actions">
<button type="button" className="ecom-quick-set-primary ecom-quick-hot-generate" onClick={handleQuickSetGenerate} disabled={!canGenerateQuickSet}>
{quickSetStatus === "generating" ? <LoadingOutlined /> : "✦"}
</button>
<button
type="button"
className={`ecom-quick-set-primary ecom-quick-set-primary--cancel${quickSetStatus !== "generating" ? " is-disabled" : ""}`}
onClick={quickSetStatus === "generating" ? handleCancelGenerate : undefined}
disabled={quickSetStatus !== "generating"}
>
</button>
</div>
</aside>
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span> </p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{quickSetStatus === "done" && quickSetResultUrls.length > 0 ? (
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
<div className="ecom-quick-set-result-grid">
{quickSetResultUrls.map((url, index) => (
<figure key={`quick-set-${index}`}>
<img src={url} alt={`套图 ${index + 1}`} />
<span> {index + 1}</span>
</figure>
))}
</div>
<button
type="button"
className="ecom-quick-detail-download"
onClick={() => {
quickSetResultUrls.forEach((url, index) => {
const link = document.createElement("a");
link.href = url;
link.download = `电商套图-${index + 1}-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
});
}}
>
<CloudUploadOutlined />
</button>
</section>
) : quickSetStatus === "generating" ? (
<section className="ecom-quick-set-generating">
<LoadingOutlined />
<strong></strong>
<span>AI ...</span>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(quickSetProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(quickSetProgress)}%</em>
</section>
) : quickSetStatus === "failed" ? (
<section className="ecom-quick-set-failed">
<FrownOutlined />
<strong></strong>
<span></span>
<button type="button" onClick={handleQuickSetGenerate} disabled={!canGenerateQuickSet}></button>
</section>
) : productImages.length ? (
<section className="ecom-quick-detail-preview-card" style={{ transform: `scale(${previewZoom})` }}>
{detailGridSamples.slice(0, 6).map((src, index) => (
<figure key={src}>
<img src={src} alt={`套图预览 ${index + 1}`} />
<span>{detailModules[index]?.title ?? "套图模块"}</span>
</figure>
))}
</section>
) : (
<section className="ecom-quick-set-empty">
<FileImageOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
</section>
</div>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传商品图后,选择平台和套图数量即可生成电商套图。")}>?</button>
</main>
);
const detailPreview = ( const detailPreview = (
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}> <main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline"> <div className="product-clone-preview__headline">
@@ -7798,6 +8447,48 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</main> </main>
); );
const copywritingPreview = (
<div key="copywriting" className="ecom-quick-page-wrap ecom-tool-page-enter">
<EcommerceCopywritingPanel onClose={closeCopywritingPage} />
</div>
);
const oneClickVideoPreview = (
<div key="oneClickVideo" className="ecom-quick-page-wrap ecom-tool-page-enter">
<EcommerceOneClickVideoPanel
onClose={closeOneClickVideoPage}
isAuthenticated={isAuthenticated}
onRequestLogin={requestLogin}
productImages={productImages}
productInputRef={productInputRef}
isProductUploadDragging={isProductUploadDragging}
setIsProductUploadDragging={setIsProductUploadDragging}
handleProductDrop={handleProductDrop}
handleProductUpload={handleProductUpload}
removeProductImage={removeProductImage}
maxProductImages={maxCloneProductImages}
requirement={requirement}
onRequirementChange={setRequirement}
platform={platform}
platformOptions={platformOptions}
onPlatformChange={handleOneClickVideoPlatformChange}
ratio={ratio}
ratioOptions={getPlatformRatioOptions(platform, "video")}
onRatioChange={setRatio}
videoQuality={cloneVideoQuality}
videoQualityOptions={cloneVideoQualityOptions}
onVideoQualityChange={setCloneVideoQuality}
videoDuration={cloneVideoDuration}
videoDurationMin={cloneVideoDurationMin}
videoDurationMax={cloneVideoDurationMax}
onVideoDurationChange={setCloneVideoDuration}
videoSmart={cloneVideoSmart}
onVideoSmartChange={setCloneVideoSmart}
onOpenHistory={() => setVideoHistoryVisible(true)}
/>
</div>
);
const activePreview = isSetTool const activePreview = isSetTool
? setPreview ? setPreview
: isDetail : isDetail
@@ -7825,9 +8516,19 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{hotClonePreview} {hotClonePreview}
</div> </div>
) )
: clonePreview : isQuickSetTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{quickSetGenPreview}
</div>
)
: isCopywritingTool
? copywritingPreview
: isOneClickVideoTool
? oneClickVideoPreview
: clonePreview
: placeholderPreview; : placeholderPreview;
const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool; const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isQuickSetTool && !isCopywritingTool && !isOneClickVideoTool;
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId); const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0); const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null; const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
@@ -7863,7 +8564,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return ( return (
<section <section
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isRecordDetailWorkspace && isCloneConversationCollapsed ? " is-conversation-collapsed" : ""}${isRecordDetailWorkspace ? " is-history-detail" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}${isQuickDetailTool ? " is-quick-set-page" : ""}${isWatermarkTool ? " is-watermark-page" : ""}${isTranslateTool ? " is-translate-page" : ""}${isImageEditTool ? " is-image-workbench-page" : ""}${isHotCloneTool ? " is-hot-clone-page" : ""}`} className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isRecordDetailWorkspace && isCloneConversationCollapsed ? " is-conversation-collapsed" : ""}${isRecordDetailWorkspace ? " is-history-detail" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}${isQuickDetailTool ? " is-quick-set-page" : ""}${isWatermarkTool ? " is-watermark-page" : ""}${isTranslateTool ? " is-translate-page" : ""}${isImageEditTool ? " is-image-workbench-page" : ""}${isHotCloneTool ? " is-hot-clone-page" : ""}${isQuickSetTool ? " is-quick-set-page" : ""}${isCopywritingTool ? " is-copywriting-page" : ""}${isOneClickVideoTool ? " is-one-click-video-page" : ""}`}
data-tool={activeTool} data-tool={activeTool}
aria-label={pageLabel} aria-label={pageLabel}
> >
@@ -7884,10 +8585,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
aria-label={`${pageLabel}参数`} aria-label={`${pageLabel}参数`}
aria-hidden={isCloneTool && isCloneSettingsCollapsed ? true : undefined} aria-hidden={isCloneTool && isCloneSettingsCollapsed ? true : undefined}
> >
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel} {isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCopywritingTool ? placeholderPanel : isCloneTool ? clonePanel : placeholderPanel}
</aside> </aside>
{isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isHotCloneTool ? ( {isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isHotCloneTool && !isQuickSetTool && !isCopywritingTool ? (
<button <button
type="button" type="button"
className="clone-ai-settings-toggle" className="clone-ai-settings-toggle"
@@ -0,0 +1,289 @@
import { useState } from "react";
import {
AppstoreOutlined,
CopyOutlined,
EditOutlined,
FileTextOutlined,
FireOutlined,
GlobalOutlined,
MessageOutlined,
SmileOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
export type CopywritingType =
| "self-media"
| "universal"
| "original"
| "imitate"
| "wechat"
| "crossborder"
| "emoji"
| "more";
interface CopywritingTypeOption {
key: CopywritingType;
label: string;
icon: React.ReactNode;
description: string;
}
const copywritingTypes: CopywritingTypeOption[] = [
{ key: "self-media", label: "自媒体文案", icon: <MessageOutlined />, description: "小红书/抖音/公众号风格" },
{ key: "universal", label: "万能写作", icon: <EditOutlined />, description: "通用场景长文短句" },
{ key: "original", label: "一键原创", icon: <ThunderboltOutlined />, description: "快速改写去重" },
{ key: "imitate", label: "文案仿写", icon: <CopyOutlined />, description: "参照爆款风格重写" },
{ key: "wechat", label: "微信营销文案", icon: <FileTextOutlined />, description: "朋友圈/社群转化文案" },
{ key: "crossborder", label: "跨境商品文案", icon: <GlobalOutlined />, description: "Amazon/Temu 卖点描述" },
{ key: "emoji", label: "文案加Emoji", icon: <SmileOutlined />, description: "自动插入表情符号" },
{ key: "more", label: "更多场景", icon: <AppstoreOutlined />, description: "持续更新中" },
];
const wordCountOptions = ["不限", "100字", "300字", "500字", "800字"];
const exampleResults: Record<CopywritingType, Array<{ title: string; body: string; points: string[] }>> = {
"self-media": [
{
title: "超值干发神器,吸水力 MAX",
body: "家人们,我发现了一款干发帽,双层加厚吸水力超强!而且只要个位数就能到手啊!",
points: [
"超强吸水力:这款干发帽采用微纤维材质,轻轻一裹,水分立马被吸走,头发快速告别湿漉漉。",
"柔软亲肤:触感超级柔软,对皮肤和头发都是温柔的抚摸,不会有摩擦伤害哦。",
"加厚设计:比普通干发帽更厚实,吸水效果自然更胜一筹,长发妹子的福音!",
"方便携带:轻巧不占空间,不论是去健身房还是旅行,携带都毫无负担。",
],
},
],
universal: [
{
title: "直接抄作业!科学的减重方法必试!",
body: "姐妹们冲鸭!有很多科学有效的方式可以帮助我们实现理想体重,今天就来分享一下必试的方法!",
points: [
"快乐有氧运动:科学的减重方式,通过有氧运动如慢跑、游泳等,能够促进脂肪燃烧,让身体更健康!",
"均衡饮食规划:摄入足够的蛋白质、蔬果以及谷物,避免过多的高糖和高脂食物,帮助达到减重目标!",
"科学计算热量:了解自己每日所需的卡路里摄入量,合理安排每餐的热量搭配,控制总摄入量。",
"坚持低强度运动:逐渐增加日常活动量,如步行、瑜伽等,通过持续的轻度运动,加速代谢!",
"合理休息调节:不要忽视睡眠的重要性,保证每晚充足的睡眠时间,帮助恢复体力和新陈代谢。",
],
},
],
original: [
{
title: "原创种草|这款干发帽真的值得入!",
body: "洗完头最烦的就是湿哒哒滴水?试试这条双层加厚干发帽,吸水速度真的惊艳到我。",
points: [
"加厚材质,吸水更快更彻底",
"柔软不勒头,长发短发都能用",
"轻便好收纳,差旅党必备",
"性价比超高,入手不亏",
],
},
],
imitate: [
{
title: "仿写爆款|让头发速干的小心机",
body: "姐妹们有没有发现,最近超火的干发帽真的太好用了!轻轻一裹,几分钟头发就半干了。",
points: [
"双层加厚,吸水力翻倍",
"柔软亲肤,不伤发质",
"小巧便携,出门也能带",
"颜值在线,多色可选",
],
},
],
wechat: [
{
title: "朋友圈文案|个位数到手的干发神器",
body: "今天必须给大家安利这个干发帽!双层加厚,吸水超强,个位数就能到手,真的不冲吗?",
points: [
"微纤维材质,轻柔速干",
"加厚设计,吸水更彻底",
"小巧便携,旅行出差都能带",
"限时好价,手慢无",
],
},
],
crossborder: [
{
title: "Amazon ListingSuper Absorbent Hair Turban",
body: "Made with ultra-soft microfiber, this double-layer hair turban dries hair quickly while protecting delicate strands.",
points: [
"Double-layer microfiber for maximum absorbency",
"Gentle on hair and skin, no frizz or breakage",
"Lightweight and travel-friendly design",
"Secure button closure stays in place",
],
},
],
emoji: [
{
title: "✨个位数到手的干发神器,吸水力 MAX!",
body: "家人们👋,我发现了一款超棒的干发帽💧,双层加厚吸水力超强!而且只要个位数就能到手啊🛒!",
points: [
"💦 超强吸水力:微纤维材质,轻轻一裹水分吸走",
"☁️ 柔软亲肤:触感温柔,不伤头发和皮肤",
"🎒 方便携带:轻巧不占空间,旅行健身都能带",
"💰 超值价格:个位数到手,性价比拉满",
],
},
],
more: [
{
title: "更多场景示例",
body: "选择左侧具体文案类型即可生成对应场景内容,更多场景持续更新中。",
points: ["选择合适的文案类型", "填写内容需求", "选择生成字数", "点击开始生成"],
},
],
};
export interface EcommerceCopywritingPanelProps {
onClose: () => void;
}
export default function EcommerceCopywritingPanel({ onClose }: EcommerceCopywritingPanelProps) {
const [selectedType, setSelectedType] = useState<CopywritingType>("self-media");
const [requirement, setRequirement] = useState("");
const [wordCount, setWordCount] = useState("不限");
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<typeof exampleResults["self-media"]>([]);
const handleGenerate = () => {
setLoading(true);
setResults([]);
// 模拟生成延迟
window.setTimeout(() => {
setResults(exampleResults[selectedType]);
setLoading(false);
}, 1200);
};
const selectedTypeLabel = copywritingTypes.find((item) => item.key === selectedType)?.label ?? "文案";
return (
<main className="ecom-copywriting-page ecom-tool-page-enter" aria-label="一键文案">
<div className="ecom-copywriting-body">
<aside className="ecom-copywriting-panel" aria-label="文案设置">
<header className="ecom-copywriting-panel-head">
<strong className="ecom-copywriting-page-title"></strong>
<button type="button" className="ecom-copywriting-back" onClick={onClose}>
</button>
<button type="button" className="ecom-copywriting-back" onClick={onClose}>
</button>
</header>
<section className="ecom-copywriting-section">
<strong className="ecom-copywriting-section-title"></strong>
<div className="ecom-copywriting-type-grid">
{copywritingTypes.map((item) => (
<button
key={item.key}
type="button"
className={`ecom-copywriting-type-card${selectedType === item.key ? " is-active" : ""}`}
onClick={() => setSelectedType(item.key)}
>
<span className="ecom-copywriting-type-icon" aria-hidden="true">
{item.icon}
</span>
<span className="ecom-copywriting-type-label">{item.label}</span>
<span className="ecom-copywriting-type-desc">{item.description}</span>
</button>
))}
</div>
</section>
<section className="ecom-copywriting-section">
<strong className="ecom-copywriting-section-title"></strong>
<textarea
className="ecom-copywriting-textarea"
value={requirement}
onChange={(event) => setRequirement(event.target.value)}
placeholder="例如:主题、核心卖点、适用人群、期望场景等"
rows={5}
/>
</section>
<section className="ecom-copywriting-section">
<strong className="ecom-copywriting-section-title"></strong>
<div className="ecom-copywriting-wordcount">
{wordCountOptions.map((item) => (
<button
key={item}
type="button"
className={wordCount === item ? "is-active" : ""}
onClick={() => setWordCount(item)}
>
{item}
</button>
))}
</div>
</section>
<button
type="button"
className="ecom-copywriting-generate"
onClick={handleGenerate}
disabled={loading}
>
{loading ? (
<>
<span className="ecom-copywriting-spinner" />
</>
) : (
<>
<ThunderboltOutlined />
</>
)}
</button>
</aside>
<section className="ecom-copywriting-stage" aria-label="生成文案预览">
<header className="ecom-copywriting-preview-head">
<h1></h1>
<p>
<span>{selectedTypeLabel}</span> AI
</p>
</header>
<div className="ecom-copywriting-results">
{results.length === 0 && !loading ? (
<div className="ecom-copywriting-empty">
<FileTextOutlined />
<strong></strong>
<em></em>
</div>
) : null}
{loading ? (
<div className="ecom-copywriting-loading">
<span className="ecom-copywriting-spinner" />
<span>AI </span>
</div>
) : null}
{results.map((item, index) => (
<article key={index} className="ecom-copywriting-result-card">
<header>
<span> {index + 1}</span>
<strong>{item.title}</strong>
</header>
<p className="ecom-copywriting-result-body">{item.body}</p>
<ul className="ecom-copywriting-result-points">
{item.points.map((point, pointIndex) => (
<li key={pointIndex}>
<span>{pointIndex + 1}</span>
{point}
</li>
))}
</ul>
</article>
))}
</div>
</section>
</div>
</main>
);
}
@@ -0,0 +1,408 @@
import {
FileImageOutlined,
PlusOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useMemo, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent, type RefObject } from "react";
import EcommerceVideoWorkspace from "../EcommerceVideoWorkspace";
interface CloneImageItem {
id: string;
src: string;
name: string;
file?: File;
}
type CloneVideoQualityKey = "standard" | "high" | "ultra";
interface EcommerceOneClickVideoPanelProps {
onClose: () => void;
isAuthenticated: boolean;
onRequestLogin: () => void;
productImages: CloneImageItem[];
productInputRef: RefObject<HTMLInputElement>;
isProductUploadDragging: boolean;
setIsProductUploadDragging: (value: boolean) => void;
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
removeProductImage: (imageId: string) => void;
maxProductImages: number;
requirement: string;
onRequirementChange: (value: string) => void;
platform: string;
platformOptions: string[];
onPlatformChange: (value: string) => void;
ratio: string;
ratioOptions: string[];
onRatioChange: (value: string) => void;
videoQuality: CloneVideoQualityKey;
videoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }>;
onVideoQualityChange: (value: CloneVideoQualityKey) => void;
videoDuration: number;
videoDurationMin: number;
videoDurationMax: number;
onVideoDurationChange: (value: number) => void;
videoSmart: boolean;
onVideoSmartChange: (value: boolean) => void;
onOpenHistory: () => void;
}
function getVideoAspectRatio(ratio: string): string {
if (ratio.includes("9:16")) return "9:16";
if (ratio.includes("16:9")) return "16:9";
if (ratio.includes("3:4")) return "3:4";
return "9:16";
}
function openQuickUploadWithKeyboard(
event: KeyboardEvent<HTMLDivElement>,
inputRef: { current: HTMLInputElement | null },
) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
inputRef.current?.click();
}
export default function EcommerceOneClickVideoPanel({
onClose,
isAuthenticated,
onRequestLogin,
productImages,
productInputRef,
isProductUploadDragging,
setIsProductUploadDragging,
handleProductDrop,
handleProductUpload,
removeProductImage,
maxProductImages,
requirement,
onRequirementChange,
platform,
platformOptions,
onPlatformChange,
ratio,
ratioOptions,
onRatioChange,
videoQuality,
videoQualityOptions,
onVideoQualityChange,
videoDuration,
videoDurationMin,
videoDurationMax,
onVideoDurationChange,
videoSmart,
onVideoSmartChange,
onOpenHistory,
}: EcommerceOneClickVideoPanelProps) {
const [openSelect, setOpenSelect] = useState<"platform" | "ratio" | null>(null);
const [planTrigger, setPlanTrigger] = useState(0);
const selectAnchorRef = useRef<HTMLDivElement>(null);
const productImageDataUrls = useMemo(() => productImages.map((img) => img.src), [productImages]);
const productImageFiles = useMemo(() => productImages.map((img) => img.file), [productImages]);
const canGenerate = productImages.length > 0 || requirement.trim().length > 0;
const handleGenerate = () => {
if (!isAuthenticated) {
onRequestLogin();
return;
}
setPlanTrigger((value) => value + 1);
};
const handlePlatformSelect = (value: string) => {
onPlatformChange(value);
setOpenSelect(null);
};
const handleRatioSelect = (value: string) => {
onRatioChange(value);
setOpenSelect(null);
};
const toggleSelect = (key: "platform" | "ratio") => {
setOpenSelect((current) => (current === key ? null : key));
};
const renderThumbs = () => (
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品原图">
{productImages.map((item) => (
<figure key={item.id} className="ecom-command-asset-thumb ecom-quick-upload-thumb">
<img src={item.src} alt={item.name} />
<span className="ecom-command-asset-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
aria-label="删除图片"
onClick={(event) => {
event.stopPropagation();
removeProductImage(item.id);
}}
>
×
</button>
</figure>
))}
</div>
);
return (
<main className="ecom-one-click-video-page ecom-quick-hot-page ecom-quick-set-page ecom-tool-page-enter" aria-label="一键视频">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="一键视频设置">
<header className="ecom-quick-set-panel-head">
<strong className="ecom-quick-set-page-title">
<VideoCameraOutlined />
</strong>
<button type="button" className="ecom-quick-set-back" onClick={onClose}>
</button>
<button type="button" className="ecom-quick-set-back" onClick={onClose}>
</button>
</header>
<section>
<strong><FileImageOutlined /> </strong>
{productImages.length ? (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material has-images${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true);
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsProductUploadDragging(false);
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsProductUploadDragging(false);
handleProductDrop(event);
}}
>
{renderThumbs()}
{productImages.length < maxProductImages ? (
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="添加更多素材"
onClick={(event) => {
event.stopPropagation();
productInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
) : null}
</div>
) : (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true);
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsProductUploadDragging(false);
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsProductUploadDragging(false);
handleProductDrop(event);
}}
>
<FileImageOutlined />
<span></span>
<em> {maxProductImages} </em>
<b>+ </b>
</div>
)}
<input
ref={productInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleProductUpload}
aria-label="上传商品图片"
/>
</section>
<section className="ecom-quick-hot-requirement">
<div className="ecom-quick-hot-requirement__head">
<strong></strong>
</div>
<div className="ecom-quick-hot-requirement__input">
<textarea
value={requirement}
onChange={(event) => onRequirementChange(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息:产品名称、核心卖点、期望场景、口播风格、具体参数"
maxLength={500}
rows={4}
/>
<span>{requirement.length}/500</span>
</div>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor" ref={selectAnchorRef}>
<div className="ecom-quick-set-selects">
<button
type="button"
className={openSelect === "platform" ? "is-active" : ""}
onClick={() => toggleSelect("platform")}
>
<span></span>
<strong>{platform}</strong>
<em></em>
</button>
<button
type="button"
className={openSelect === "ratio" ? "is-active" : ""}
onClick={() => toggleSelect("ratio")}
>
<span></span>
<strong>{ratio.replace(/\s+/g, " ").trim()}</strong>
<em></em>
</button>
</div>
{openSelect ? (
<div
className="ecom-quick-set-dropdown"
role="listbox"
aria-label={openSelect === "platform" ? "平台" : "尺寸比例"}
>
{(openSelect === "platform" ? platformOptions : ratioOptions).map((option) => (
<button
key={option}
type="button"
className={
(openSelect === "platform" ? platform === option : ratio === option) ? "is-active" : ""
}
role="option"
aria-selected={openSelect === "platform" ? platform === option : ratio === option}
onClick={() => {
if (openSelect === "platform") {
handlePlatformSelect(option);
} else {
handleRatioSelect(option);
}
}}
>
{option.replace(/\s+/g, " ").trim()}
</button>
))}
</div>
) : null}
</div>
</section>
<section>
<strong></strong>
<div className="ecom-quick-detail-modules">
{videoQualityOptions.map((option) => (
<button
key={option.key}
type="button"
className={videoQuality === option.key ? "is-active" : ""}
aria-pressed={videoQuality === option.key}
onClick={() => onVideoQualityChange(option.key)}
>
<strong>{option.label}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</section>
<section>
<strong></strong>
<div className="ecom-one-click-video-duration">
<span>{videoDuration} </span>
<input
type="range"
className="ecom-one-click-video-range"
min={videoDurationMin}
max={videoDurationMax}
step={5}
value={videoDuration}
onChange={(event) => onVideoDurationChange(Number(event.target.value))}
aria-label="视频时长"
/>
<div className="ecom-one-click-video-duration-scale" aria-hidden="true">
<span>{videoDurationMin}</span>
<span>{videoDurationMax}</span>
</div>
</div>
</section>
<section>
<button
type="button"
className={`ecom-one-click-video-smart${videoSmart ? " is-on" : ""}`}
aria-pressed={videoSmart}
onClick={() => onVideoSmartChange(!videoSmart)}
>
<span>
<strong></strong>
<em></em>
</span>
<i aria-hidden="true" />
</button>
</section>
<div className="ecom-quick-hot-actions">
<button
type="button"
className="ecom-quick-set-primary ecom-one-click-video-generate"
onClick={handleGenerate}
disabled={!canGenerate}
>
<ThunderboltOutlined /> {isAuthenticated ? "一键生成视频" : "登录后生成"}
</button>
</div>
</aside>
<section className="ecom-quick-set-stage">
<EcommerceVideoWorkspace
isAuthenticated={isAuthenticated}
productImageDataUrls={productImageDataUrls}
productImageFiles={productImageFiles}
requirement={requirement}
platform={platform}
aspectRatio={getVideoAspectRatio(ratio)}
durationSeconds={videoDuration}
resolution={videoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={onRequestLogin}
onOpenHistory={onOpenHistory}
triggerPlan={planTrigger}
/>
</section>
</div>
</main>
);
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -371,3 +371,14 @@
border-color: rgba(var(--accent-rgb), 0.42); border-color: rgba(var(--accent-rgb), 0.42);
background: var(--bg-panel); background: var(--bg-panel);
} }
/* ── Product set count stepper: align with local light theme ── */
html body #root .ecommerce-standalone .product-clone-page[data-tool="clone"] .clone-ai-count-stepper {
border-color: var(--border-subtle) !important;
background: var(--bg-inset) !important;
color: var(--fg-body) !important;
}
html body #root .ecommerce-standalone .product-clone-page[data-tool="clone"] .clone-ai-count-stepper b {
color: var(--fg-body) !important;
}