Compare commits

..

13 Commits

Author SHA1 Message Date
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
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
stringadmin 57cf34b0d0 style: local ecommerce-standalone.css changes (authority sync) 2026-06-16 22:50:14 +08:00
stringadmin c7adbc153b fix: restore ecommerce platform rule type imports 2026-06-16 21:40:07 +08:00
stringadmin 17152efa2c Merge branch 'main-merge-work' of ssh://118.145.251.184-port2222/OmniAI/omniai-ds-code-package into main-merge-work 2026-06-16 21:37:38 +08:00
stringadmin a605fad7e0 Merge origin/main into main-merge-work 2026-06-16 21:33:41 +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
stringadmin 4ca2ab4a9c Merge origin/main into main-merge-work (resolve EcommercePage/CSS conflicts) 2026-06-16 21:13:25 +08:00
stringadmin 588da45902 refactor: optimize Topbar scroll listener; sync WIP ecommerce refactor and CSS 2026-06-16 21:09:41 +08:00
stringadmin 5466036349 refactor: extract Topbar and LocalAvatar components from App.tsx 2026-06-16 20:15:53 +08:00
stringadmin 9869c0c5e6 Merge pull request 'feat: refactor ecommerce toolbar from mode tabs to scenario-based tabs with rich template cards' (#20) from feat/ecommerce-scenario-tabs into main
Reviewed-on: #20
2026-06-16 11:16:46 +00:00
stringadmin c38f056527 style: make topbar fixed transparent floating header 2026-06-16 16:39:58 +08:00
7 changed files with 1596 additions and 213 deletions
+14 -8
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@ant-design/icons": "5.3.0",
"@phosphor-icons/react": "^2.1.10",
"react": "18.2.0",
"react-dom": "18.2.0",
"scheduler": "0.23.0",
@@ -119,7 +120,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -851,6 +851,19 @@
"@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": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
@@ -1296,7 +1309,6 @@
"integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -1581,7 +1593,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -2710,7 +2721,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2723,7 +2733,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
@@ -2794,7 +2803,6 @@
"integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -3166,7 +3174,6 @@
"integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.19.3",
"postcss": "^8.4.35",
@@ -3257,7 +3264,6 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1",
+1
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@ant-design/icons": "5.3.0",
"@phosphor-icons/react": "^2.1.10",
"react": "18.2.0",
"react-dom": "18.2.0",
"scheduler": "0.23.0",
+35 -108
View File
@@ -4,19 +4,16 @@ import {
CheckCircleFilled,
CloseOutlined,
HomeOutlined,
IdcardOutlined,
LockOutlined,
LoadingOutlined,
LoginOutlined,
LogoutOutlined,
MailOutlined,
MobileOutlined,
PictureOutlined,
SafetyOutlined,
UserOutlined,
VideoCameraOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { LocalAvatar } from "./components/LocalAvatar";
import { Topbar } from "./components/Topbar";
import ErrorBoundary from "./components/ErrorBoundary";
import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
@@ -40,6 +37,9 @@ const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
type AuthMode = "login" | "register";
type AuthMethod = "account" | "email" | "phone";
type WorkspaceChromeState = {
isToolPage: boolean;
};
interface LocalProfilePageProps {
session: WebUserSession;
@@ -51,17 +51,6 @@ interface LocalProfilePageProps {
onLogout: () => void;
}
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
const displayName = session.user.displayName || session.user.username || "用户";
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
const avatarUrl = session.user.avatarUrl;
return (
<span className={`local-user-avatar local-user-avatar--${size}`}>
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : <span>{label}</span>}
</span>
);
}
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
const displayName = session.user.displayName || session.user.username || "用户";
const workCount = Math.max(imageCount + videoCount, 0);
@@ -166,6 +155,9 @@ function App() {
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
const [workspaceChrome, setWorkspaceChrome] = useState<WorkspaceChromeState>({
isToolPage: false,
});
useEffect(() => {
void loadDarkGreenTheme();
@@ -318,20 +310,6 @@ function App() {
};
const balance = Math.max(usage.balanceCents, 0) / 100;
const displayName = session?.user.displayName || session?.user.username || "用户";
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
const shownWorkCount = actualWorkCount;
const avatarMenuStats = useMemo(
() => [
{ icon: <IdcardOutlined />, label: "UID", value: session?.user.id ?? "-" },
{ icon: <WalletOutlined />, label: "积分", value: `${balance.toFixed(2)} 积分` },
{ icon: <PictureOutlined />, label: "图片", value: usage.imageUsed },
{ icon: <VideoCameraOutlined />, label: "视频", value: usage.videoUsed },
{ icon: <PictureOutlined />, label: "作品", value: shownWorkCount },
],
[balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed],
);
const handleOpenProfile = () => {
setProfileMenuOpen(false);
@@ -349,86 +327,31 @@ function App() {
};
return (
<div className="ecommerce-standalone web-shell" data-theme="dark" data-ui-theme="dark-green" data-view="ecommerce">
<header className="ecommerce-standalone__topbar">
<button type="button" className="ecommerce-standalone__brand" onClick={handleOpenWorkspace}>
<span className="ecommerce-standalone__logo" aria-hidden="true">
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
</span>
<strong>OmniAI </strong>
</button>
<div className="ecommerce-standalone__account">
{session ? (
<div className="ecommerce-profile-menu">
<span className="ecommerce-standalone__credits">
{(Math.max(usage.balanceCents, 0) / 100).toFixed(2)}
</span>
<button
type="button"
className="ecommerce-profile-menu__trigger"
onClick={() => setProfileMenuOpen((open) => !open)}
aria-haspopup="dialog"
aria-expanded={profileMenuOpen}
>
<LocalAvatar session={session} size="sm" />
<span>{displayName}</span>
</button>
{profileMenuOpen ? (
<>
<button
type="button"
className="ecommerce-profile-popover__backdrop"
aria-label="关闭账户信息"
onClick={() => setProfileMenuOpen(false)}
/>
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
<div className="ecommerce-profile-popover__head">
<LocalAvatar session={session} size="md" />
<div>
<strong>{displayName}</strong>
<span>{session.user.username}</span>
</div>
</div>
<dl className="ecommerce-profile-popover__stats">
{avatarMenuStats.map((item) => (
<div key={item.label}>
<dt>{item.icon}{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
<div className="ecommerce-profile-popover__actions">
<button type="button" className="is-primary" onClick={handleOpenProfile}>
<UserOutlined />
</button>
<button type="button" onClick={handleBugFeedback}>
<BugOutlined />
Bug
</button>
<button type="button" className="is-danger" onClick={handleLogout}>
<LogoutOutlined />
退
</button>
</div>
</section>
</>
) : null}
</div>
) : (
<button type="button" onClick={() => openAuth("login")}>
<LoginOutlined />
<span> / </span>
</button>
)}
</div>
</header>
<div
className="ecommerce-standalone web-shell"
data-theme="dark"
data-ui-theme="dark-green"
data-view="ecommerce"
data-workspace-tool-page={workspaceChrome.isToolPage ? "true" : "false"}
>
<Topbar
session={session}
usage={usage}
profileMenuOpen={profileMenuOpen}
onProfileMenuOpenChange={setProfileMenuOpen}
onOpenWorkspace={handleOpenWorkspace}
onOpenProfile={handleOpenProfile}
onOpenAuth={openAuth}
onLogout={handleLogout}
onBugFeedback={handleBugFeedback}
/>
<main className="ecommerce-standalone__content">
{session ? (
<div className="ecommerce-standalone__page" hidden={currentPage !== "profile"}>
<div
className="ecommerce-standalone__page ecommerce-standalone__page--profile"
hidden={currentPage !== "profile"}
>
<LocalProfilePage
session={session}
balance={balance}
@@ -442,7 +365,10 @@ function App() {
) : null}
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
<div className="ecommerce-standalone__page" hidden={Boolean(session) && currentPage === "profile"}>
<div
className="ecommerce-standalone__page ecommerce-standalone__page--workspace"
hidden={Boolean(session) && currentPage === "profile"}
>
<ErrorBoundary>
<Suspense
fallback={
@@ -455,6 +381,7 @@ function App() {
<EcommercePage
projects={[]}
isAuthenticated={Boolean(session)}
onWorkspaceChromeChange={setWorkspaceChrome}
onStartCreate={() => undefined}
onOpenProject={() => undefined}
onDeleteProject={() => undefined}
+17
View File
@@ -0,0 +1,17 @@
import type { WebUserSession } from "../types";
interface LocalAvatarProps {
session: WebUserSession;
size?: "sm" | "md" | "lg";
}
export function LocalAvatar({ session, size = "md" }: LocalAvatarProps) {
const displayName = session.user.displayName || session.user.username || "用户";
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
const avatarUrl = session.user.avatarUrl;
return (
<span className={`local-user-avatar local-user-avatar--${size}`}>
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : <span>{label}</span>}
</span>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useMemo, useState } from "react";
import {
BugOutlined,
IdcardOutlined,
LoginOutlined,
LogoutOutlined,
PictureOutlined,
UserOutlined,
VideoCameraOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { LocalAvatar } from "./LocalAvatar";
import type { WebUserSession } from "../types";
interface TopbarProps {
session: WebUserSession | null;
usage: { balanceCents: number; imageUsed: number; videoUsed: number };
profileMenuOpen: boolean;
onProfileMenuOpenChange: (open: boolean) => void;
onOpenWorkspace: () => void;
onOpenProfile: () => void;
onOpenAuth: (mode: "login" | "register") => void;
onLogout: () => void;
onBugFeedback: () => void;
}
export function Topbar({
session,
usage,
profileMenuOpen,
onProfileMenuOpenChange,
onOpenWorkspace,
onOpenProfile,
onOpenAuth,
onLogout,
onBugFeedback,
}: TopbarProps) {
const [isTopbarHidden, setIsTopbarHidden] = useState(false);
useEffect(() => {
let restoreTimer: number | undefined;
const handleScroll = (event: Event) => {
if (profileMenuOpen) return;
const target = event.target;
const activeWorkspace = document.querySelector<HTMLElement>(".ecommerce-standalone__page--workspace:not([hidden])");
if (!activeWorkspace) return;
const isWorkspacePreviewScroll =
target instanceof HTMLElement && target.classList.contains("clone-ai-preview") && activeWorkspace.contains(target);
const isPageScroll =
target === document ||
target === document.scrollingElement ||
target === document.documentElement ||
target === document.body;
if (!isWorkspacePreviewScroll && !isPageScroll) return;
setIsTopbarHidden(true);
if (restoreTimer) window.clearTimeout(restoreTimer);
restoreTimer = window.setTimeout(() => {
setIsTopbarHidden(false);
}, 240);
};
window.addEventListener("scroll", handleScroll, { capture: true, passive: true });
return () => {
window.removeEventListener("scroll", handleScroll, { capture: true });
if (restoreTimer) window.clearTimeout(restoreTimer);
};
}, [profileMenuOpen]);
const balance = Math.max(usage.balanceCents, 0) / 100;
const displayName = session?.user.displayName || session?.user.username || "用户";
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
const shownWorkCount = actualWorkCount;
const avatarMenuStats = useMemo(
() => [
{ icon: <IdcardOutlined />, label: "UID", value: session?.user.id ?? "-" },
{ icon: <WalletOutlined />, label: "积分", value: `${balance.toFixed(2)} 积分` },
{ icon: <PictureOutlined />, label: "图片", value: usage.imageUsed },
{ icon: <VideoCameraOutlined />, label: "视频", value: usage.videoUsed },
{ icon: <PictureOutlined />, label: "作品", value: shownWorkCount },
],
[balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed],
);
return (
<header
className="ecommerce-standalone__topbar"
data-scroll-hidden={isTopbarHidden ? "true" : "false"}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
pointerEvents: "none",
background: "transparent",
border: 0,
boxShadow: "none",
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
<button
type="button"
className="ecommerce-standalone__brand"
style={{ pointerEvents: "auto" }}
onClick={onOpenWorkspace}
>
<span className="ecommerce-standalone__logo" aria-hidden="true">
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
</span>
<strong>OmniAI </strong>
</button>
<div className="ecommerce-standalone__account">
{session ? (
<div className="ecommerce-profile-menu">
<button
type="button"
className="ecommerce-profile-menu__trigger"
style={{ pointerEvents: "auto" }}
onClick={() => onProfileMenuOpenChange(!profileMenuOpen)}
aria-haspopup="dialog"
aria-expanded={profileMenuOpen}
>
<span className="ecommerce-standalone__credits">
{(Math.max(usage.balanceCents, 0) / 100).toFixed(2)}
</span>
<LocalAvatar session={session} size="sm" />
<span className="ecommerce-profile-menu__name">{displayName}</span>
</button>
{profileMenuOpen ? (
<>
<button
type="button"
className="ecommerce-profile-popover__backdrop"
aria-label="关闭账户信息"
onClick={() => onProfileMenuOpenChange(false)}
/>
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
<div className="ecommerce-profile-popover__head">
<LocalAvatar session={session} size="md" />
<div>
<strong>{displayName}</strong>
<span>{session.user.username}</span>
</div>
</div>
<dl className="ecommerce-profile-popover__stats">
{avatarMenuStats.map((item) => (
<div key={item.label}>
<dt>
{item.icon}
{item.label}
</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
<div className="ecommerce-profile-popover__actions">
<button type="button" className="is-primary" onClick={onOpenProfile}>
<UserOutlined />
</button>
<button type="button" onClick={onBugFeedback}>
<BugOutlined />
Bug
</button>
<button type="button" className="is-danger" onClick={onLogout}>
<LogoutOutlined />
退
</button>
</div>
</section>
</>
) : null}
</div>
) : (
<button
type="button"
className="ecommerce-standalone__login-button"
style={{ pointerEvents: "auto" }}
onClick={() => onOpenAuth("login")}
>
<LoginOutlined />
<span> / </span>
</button>
)}
</div>
</header>
);
}
+293 -53
View File
@@ -25,6 +25,17 @@ import {
TableOutlined,
VideoCameraOutlined,
} 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 { createPortal } from "react-dom";
import { useTypewriter } from "../../hooks/useTypewriter";
@@ -41,6 +52,7 @@ import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
import { downloadResultAsset } from "../workbench/workbenchDownload";
import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules";
const smartCutoutColorPresets = [
"#ffffff",
@@ -270,8 +282,6 @@ interface ProductClonePageProps {
}
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot";
type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
@@ -1045,16 +1055,17 @@ const cloneOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc:
...productSetOutputOptions,
];
const commerceScenarioOptions: Array<{ key: CommerceScenarioKey; label: string; desc: string; icon: ReactNode }> = [
{ key: "popular", label: "热门", desc: "高频模板", icon: <FireOutlined /> },
{ key: "poster", label: "海报生成", desc: "活动视觉", icon: <LayoutOutlined /> },
{ key: "mainImage", label: "商品主图", desc: "主图转化", icon: <FileImageOutlined /> },
{ key: "scene", label: "场景图", desc: "生活氛围", icon: <AppstoreOutlined /> },
{ key: "festival", label: "节日风格图", desc: "节点营销", icon: <GlobalOutlined /> },
{ key: "model", label: "模特图", desc: "真人展示", icon: <SkinOutlined /> },
{ key: "background", label: "更换背景", desc: "背景重构", icon: <ClearOutlined /> },
{ key: "retouch", label: "无痕改图", desc: "精修优化", icon: <EditOutlined /> },
{ key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: <VideoCameraOutlined /> },
{ 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: "background", label: "更换背景", desc: "背景重构", icon: <span role="img" aria-label="background"></span> },
{ key: "retouch", label: "无痕改图", desc: "精修优化", icon: <span role="img" aria-label="retouch">🪄</span> },
{ 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> = {
poster: "set",
mainImage: "set",
@@ -1226,6 +1237,166 @@ const commerceScenarioTemplates: CommerceScenarioTemplate[] = [
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,
},
];
const popularCommerceScenarioTemplates = commerceScenarioOptions
.filter((option): option is { key: Exclude<CommerceScenarioKey, "popular">; label: string; desc: string; icon: ReactNode } => option.key !== "popular")
@@ -1674,6 +1845,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const composerMenuCloseTimeoutRef = useRef<number | null>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const commandComposerWrapRef = useRef<HTMLElement | null>(null);
const templateStripRef = useRef<HTMLElement | null>(null);
const garmentInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const detailProgressRef = useRef<number | null>(null);
@@ -1748,9 +1920,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState<string | null>(null);
const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0);
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 [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(true);
const [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(false);
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false);
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
@@ -2254,9 +2427,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const activeCommerceScenarioTemplates = activeCommerceScenario === "popular"
? popularCommerceScenarioTemplates
: commerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario);
const visibleCommerceScenarioOptions = useMemo(
() =>
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 =
cloneOutput === "model"
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描述(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
@@ -3664,6 +3849,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
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 normalizedMarket = normalizeMarket(nextMarket);
setMarket(normalizedMarket);
@@ -5746,6 +5948,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
const handleCloneTemplateCardClick = (card: CommerceScenarioTemplate) => {
setActiveCommerceScenario(card.scenario);
if (card.output !== cloneOutput) handleCloneOutputChange(card.output);
setIsCloneTemplateStripVisible(true);
setComposerMenu(null);
@@ -6085,23 +6288,38 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onChange={handleSmartCutoutUpload}
aria-label="上传智能抠图素材"
/>
<div className="ecom-command-mode-tabs ecom-command-scenario-tabs" aria-label="电商场景">
{commerceScenarioOptions.map((option) => (
<div className="ecom-command-scenario-shell" data-expanded={isCommerceScenarioMoreOpen ? "true" : "false"}>
<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
key={option.key}
type="button"
className={`${activeCommerceScenario === option.key ? "is-active" : ""}${activeCommerceScenario === option.key && isCloneTemplateStripVisible ? " is-open" : ""}`}
onClick={() => handleCommerceScenarioClick(option.key)}
className={`ecom-command-scenario-more${isCommerceScenarioMoreOpen ? " is-open" : ""}`}
onClick={handleCommerceScenarioMoreToggle}
aria-expanded={isCommerceScenarioMoreOpen}
>
<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}
<span className="ecom-command-mode-icon ecom-command-mode-icon--more" aria-hidden="true">
{isCommerceScenarioMoreOpen ? <CloseOutlined /> : "···"}
</span>
<strong>{isCommerceScenarioMoreOpen ? "收起" : "更多"}</strong>
</button>
))}
</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">
{productImages.length ? (
<div className="ecom-command-asset-popover" aria-label={`已上传素材,${productImages.length}/${maxCloneProductImages}`}>
@@ -6194,31 +6412,53 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
{renderComposerMenu()}
</div>
{(status === "idle" || status === "ready") && !showMainVideoWorkspace && isCloneTemplateStripVisible ? (
<section className={`ecom-command-template-strip ecom-command-template-strip--${activeCommerceScenario}`} aria-label="模板卡片">
{activeCommerceScenarioTemplates.map((card) => (
<button
key={card.id}
type="button"
className="ecom-command-template-card"
aria-label={card.title}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
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>
{(status === "idle" || status === "ready") && !showMainVideoWorkspace && activeCommerceScenario !== null && isCloneTemplateStripVisible ? (
<div className={`ecom-command-template-carousel ecom-command-template-carousel--${activeCommerceScenario}`}>
<button
type="button"
className="ecom-command-template-nav ecom-command-template-nav--prev"
onClick={() => scrollCommerceTemplateStrip(-1)}
aria-label="查看上一组模板"
>
</button>
<section
ref={templateStripRef}
className={`ecom-command-template-strip ecom-command-template-strip--${activeCommerceScenario}`}
aria-label="模板卡片"
>
{activeCommerceScenarioTemplates.map((card) => (
<button
key={card.id}
type="button"
className="ecom-command-template-card"
aria-label={card.title}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
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}
{(status === "idle" || status === "ready") && !showMainVideoWorkspace ? (
<section className="ecom-command-quick-board" aria-label="快捷功能">
File diff suppressed because it is too large Load Diff