Compare commits

...

9 Commits

Author SHA1 Message Date
stringadmin 9945008b94 Merge pull request 'feat: ecommerce quick tool UI responsive polish' (#34) from feat/ecommerce-ui-responsive-polish-20260618 into main
CI / verify (push) Waiting to run
Reviewed-on: #34
2026-06-18 10:36:28 +00:00
Codex 426e670934 feat: ecommerce quick tool UI responsive polish
CI / verify (pull_request) Waiting to run
2026-06-18 18:35:48 +08:00
stringadmin 5c07f0794a Merge pull request 'feat: 记录详情页Canvas按轮次分组展示,对话面板UI重构与视觉升级' (#33) from feat/ecommerce-record-detail-canvas-groups into main
CI / verify (push) Has been cancelled
Reviewed-on: #33
2026-06-18 10:31:57 +00:00
ludan ffd871490e merge main: 解决EcommercePage.tsx和ecommerce-standalone.css冲突
CI / verify (pull_request) Has been cancelled
2026-06-18 18:31:10 +08:00
ludan 7795ca3cbb feat: 记录详情页Canvas按轮次分组展示,对话面板UI重构与视觉升级
CI / verify (pull_request) Has been cancelled
- Canvas视图重构:记录详情页用 turn-groups 按生成轮次分组替代原 canvas-nodes,支持生成中/失败状态展示及拖拽定位
- 对话面板头部改为 title + actions 布局,新增首页返回按钮,移除独立 toggle
- 对话收起时显示 recall 入口卡片,展示当前轮次摘要信息并支持一键展开继续对话
- 历史记录侧边栏列表项新增状态 class(is-generating/is-failed/is-done),元信息拆分为类型+状态标签结构
- CSS 新增 1772 行:记录详情页整体视觉升级(渐变背景、毛玻璃面板、胶囊卡片、状态色条),Canvas 节点响应式布局,Preview modal 底部操作栏美化
2026-06-18 18:28:57 +08:00
stringadmin c70affc180 Merge pull request 'feat: localize ecommerce quick tool pages' (#32) from codex/ecommerce-ui-latest-responsive-20260618 into main
CI / verify (push) Has been cancelled
Reviewed-on: #32
2026-06-18 08:33:06 +00:00
stringadmin 7056ed0dd2 Merge branch 'main' into codex/ecommerce-ui-latest-responsive-20260618
CI / verify (pull_request) Has been cancelled
2026-06-18 08:32:59 +00:00
stringadmin c09bbddaf6 Merge pull request 'Codex/ecommerce history sync' (#31) from codex/ecommerce-history-sync into main
CI / verify (push) Has been cancelled
Reviewed-on: #31
2026-06-18 08:32:50 +00:00
Codex d7e6f03157 feat: localize ecommerce quick tool pages
CI / verify (pull_request) Has been cancelled
2026-06-18 16:19:59 +08:00
5 changed files with 3192 additions and 182 deletions
+548 -173
View File
@@ -65,8 +65,10 @@ import {
getPlatformDefaultRatio,
getPlatformLanguageOptions,
getPlatformRatioOptions,
languageOptions,
marketLanguageOptions,
marketOptions,
normalizeLanguage,
normalizeLanguageForPlatform,
normalizeMarket,
normalizePlatform,
@@ -167,6 +169,20 @@ type SmartCutoutImageItem = { src: string; name: string; originalSrc?: string };
const ecommerceInspirationTabs = ["最近打开", "一键同款", "海报模板", "热门", "商品图", "模特穿戴"];
const ecommerceInspirationAssets = ossAssets.ecommerce.inspiration;
const getMarketsForLanguage = (languageValue: string) => {
const normalizedLanguage = normalizeLanguage(languageValue);
const matches = marketLanguageOptions
.filter((option) => option.languages.some((item) => normalizeLanguage(item) === normalizedLanguage))
.map((option) => option.country);
return matches.length ? matches : marketOptions;
};
const normalizeMarketForLanguage = (marketValue: string, languageValue: string) => {
const normalizedMarket = normalizeMarket(marketValue);
const languageMarkets = getMarketsForLanguage(languageValue);
return languageMarkets.includes(normalizedMarket) ? normalizedMarket : (languageMarkets[0] ?? marketOptions[0] ?? normalizedMarket);
};
const ecommerceInspirationRows = [
{
title: "作品记录",
@@ -313,6 +329,20 @@ interface CanvasNode {
y: number;
}
interface RecordCanvasGroup {
id: string;
mode: string;
outputLabel: string;
settingLabel: string;
sourceImages: CloneImageItem[];
results: CloneResult[];
createdAt: number;
turnIndex: number;
status: EcommerceHistoryStatus;
x: number;
y: number;
}
interface PreviewTouchPoint {
id: number;
x: number;
@@ -341,9 +371,6 @@ interface EcommerceImagePromptOptions {
}
const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [
{ key: "set", label: "商品套图", icon: <AppstoreOutlined /> },
{ key: "detail", label: "A+详情", icon: <FileImageOutlined /> },
{ key: "wear", label: "服饰穿搭", icon: <SkinOutlined /> },
{ key: "clone", label: "电商AI作图", icon: <AppstoreOutlined /> },
];
@@ -1185,6 +1212,18 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const skipInitialCloneAutoSaveRef = useRef(true);
const skipNextCloneAutoSaveRef = useRef(false);
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
useEffect(() => {
if (activeTool === "set") {
setActiveTool("clone");
setActiveQuickTool("quick-set");
} else if (activeTool === "detail") {
setActiveTool("clone");
setActiveQuickTool("detail");
} else if (activeTool === "wear") {
setActiveTool("clone");
setActiveQuickTool(null);
}
}, [activeTool]);
useEffect(() => {
setPreviewZoom(1);
setIsCommandComposerCompact(false);
@@ -1730,7 +1769,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [detailProgress, setDetailProgress] = useState(0);
const [hotRequirement, setHotRequirement] = useState("");
const [isHotMaterialDragging, setIsHotMaterialDragging] = useState(false);
const [hotMaterialHoverZoom, setHotMaterialHoverZoom] = useState<{ src: string; x: number; y: number; placement: "above" | "below" } | null>(null);
const [hotMaterialHoverZoom, setHotMaterialHoverZoom] = useState<{ src: string; x: number; y: number; placement: "right" | "left" } | null>(null);
const [hotPlatform, setHotPlatform] = useState(platformOptions[0]);
const [hotMarket, setHotMarket] = useState(marketOptions[0]);
const [hotLanguage, setHotLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0]));
@@ -1793,6 +1832,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
() => getPlatformLanguageOptions(hotPlatform, hotMarket),
[hotMarket, hotPlatform],
);
const languageMarketOptions = languageOptions;
const cloneMarketOptions = useMemo(() => getMarketsForLanguage(language), [language]);
const detailMarketOptions = useMemo(() => getMarketsForLanguage(detailLanguage), [detailLanguage]);
const hotMarketOptions = useMemo(() => getMarketsForLanguage(hotLanguage), [hotLanguage]);
const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
@@ -1807,6 +1850,43 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
[productImages],
);
const quickPageSidebarItems: Array<{ key: NonNullable<typeof activeQuickTool>; label: string; icon: ReactNode }> = [
{ key: "quick-set", label: "商品套图", icon: <AppstoreAddOutlined /> },
{ key: "detail", label: "A+详情", icon: <LayoutOutlined /> },
{ key: "hot", label: "爆款复刻", icon: <FireOutlined /> },
{ key: "oneClickVideo", label: "一键视频", icon: <PlayCircleOutlined /> },
{ key: "image-edit", label: "图片修改", icon: <HighlightOutlined /> },
{ key: "watermark", label: "去除水印", icon: <ClearOutlined /> },
{ key: "copywriting", label: "一键文案", icon: <FileTextOutlined /> },
{ key: "translate", label: "图片翻译", icon: <TranslationOutlined /> },
];
const commerceQuickPageSidebarItems = quickPageSidebarItems.filter((item) =>
["quick-set", "detail", "hot", "oneClickVideo"].includes(item.key),
);
const visualQuickPageSidebarItems = quickPageSidebarItems.filter((item) =>
["image-edit", "watermark", "copywriting", "translate"].includes(item.key),
);
const renderQuickPageSidebar = (
activeKey: NonNullable<typeof activeQuickTool>,
group: "commerce" | "visual" = "commerce",
) => (
<nav className="ecom-quick-page-sidebar" aria-label="快捷工具切换">
{(group === "visual" ? visualQuickPageSidebarItems : commerceQuickPageSidebarItems).map((item) => (
<button
key={item.key}
type="button"
className={item.key === activeKey ? "is-active" : ""}
onClick={() => setActiveQuickTool(item.key)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</nav>
);
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
@@ -2209,8 +2289,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const openImageTranslatePage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("translate");
setComposerMenu(null);
toast.info("功能正在优化中");
};
const closeImageTranslatePage = () => {
@@ -3255,7 +3335,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setRatio((current) =>
normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput),
);
setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market));
};
const handleCloneOutputChange = (nextOutput: CloneOutputKey) => {
@@ -3305,10 +3384,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setLanguage(getPlatformDefaultLanguage(platform, normalizedMarket));
};
const handleCloneLanguageChange = (nextLanguage: string) => {
const normalizedLanguage = normalizeLanguage(nextLanguage);
setLanguage(normalizedLanguage);
setMarket((current) => normalizeMarketForLanguage(current, normalizedLanguage));
};
const handleDetailPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setDetailPlatform(normalizedPlatform);
setDetailLanguage(getPlatformDefaultLanguage(normalizedPlatform, detailMarket));
setDetailRatio((current) => getQuickSetRatioValue(current));
};
@@ -3318,6 +3402,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setDetailLanguage(getPlatformDefaultLanguage(detailPlatform, normalizedMarket));
};
const handleDetailLanguageChange = (nextLanguage: string) => {
const normalizedLanguage = normalizeLanguage(nextLanguage);
setDetailLanguage(normalizedLanguage);
setDetailMarket((current) => normalizeMarketForLanguage(current, normalizedLanguage));
};
const createCloneSettingSnapshot = (name: string, id = `clone-setting-${Date.now()}`): CloneSavedSetting => ({
id,
name,
@@ -4462,7 +4552,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleHotPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setHotPlatform(normalizedPlatform);
setHotLanguage(getPlatformDefaultLanguage(normalizedPlatform, hotMarket));
setHotRatio((current) => getQuickSetRatioValue(current));
};
@@ -4472,6 +4561,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setHotLanguage(getPlatformDefaultLanguage(hotPlatform, normalizedMarket));
};
const handleHotLanguageChange = (nextLanguage: string) => {
const normalizedLanguage = normalizeLanguage(nextLanguage);
setHotLanguage(normalizedLanguage);
setHotMarket((current) => normalizeMarketForLanguage(current, normalizedLanguage));
};
const handleHotAiWrite = () => {
setHotRequirement(
"1.产品名称:便携式咖啡保温杯\n2.核心卖点:316不锈钢内胆、12小时长效保温、防漏便携、大容量\n3.参考风格:极简日系、暖光氛围、生活场景\n4.期望场景:办公桌面、户外通勤、运动健身\n5.具体参数:容量500ml、口径4.5cm、高度22cm",
@@ -4587,20 +4682,19 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleHotMaterialMouseEnter = (src: string, event: ReactMouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const previewHalfWidth = 150;
const previewHeight = 360;
const previewWidth = 300;
const previewHeight = 190;
const gap = 12;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const x = Math.min(
Math.max(rect.left + rect.width / 2, previewHalfWidth + gap),
Math.max(previewHalfWidth + gap, viewportWidth - previewHalfWidth - gap),
const canShowRight = rect.right + gap + previewWidth <= viewportWidth - gap;
const placement: "right" | "left" = canShowRight ? "right" : "left";
const x = placement === "right" ? rect.right + gap : Math.max(gap, rect.left - gap);
const y = Math.min(
Math.max(rect.top + rect.height / 2, previewHeight / 2 + gap),
Math.max(previewHeight / 2 + gap, viewportHeight - previewHeight / 2 - gap),
);
const showAbove = rect.top > previewHeight + gap;
const y = showAbove
? rect.top - gap
: Math.min(rect.bottom + gap, viewportHeight - gap);
setHotMaterialHoverZoom({ src, x, y, placement: showAbove ? "above" : "below" });
setHotMaterialHoverZoom({ src, x, y, placement });
};
const handleHotMaterialMouseLeave = () => setHotMaterialHoverZoom(null);
@@ -4624,13 +4718,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onRemove(item.id);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M9 6V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1" />
<path d="M5 6h14" />
<path d="M8 6l1 14h6l1-14" />
<path d="M10.5 10v6" />
<path d="M13.5 10v6" />
</svg>
</button>
</figure>
))}
@@ -4779,6 +4867,65 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
isOneClickVideoTool ||
isVideoWorkspaceVisible ||
Boolean(activeHistoryRecordId);
const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isQuickSetTool && !isCopywritingTool && !isOneClickVideoTool;
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
const activeConversationTurns = activeHistoryRecord
? activeHistoryRecord.turns?.length
? activeHistoryRecord.turns
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
: [];
const activeRecallTurn = activeConversationTurns[activeConversationTurns.length - 1];
const commandRecallPrompt =
requirement.trim() ||
activeRecallTurn?.requirement?.trim() ||
activeHistoryRecord?.requirement?.trim() ||
"展开完整对话,继续补充生成需求。";
const commandRecallAsset = productImages[0] || activeRecallTurn?.productImages?.[0] || activeHistoryRecord?.productImages?.[0];
const commandRecallStatus =
status === "generating"
? "生成中"
: activeRecallTurn?.status === "failed" || status === "failed"
? "可恢复"
: "继续生成";
const hasGeneratedCloneWork = status === "done" || canvasNodes.length > 0;
const shouldUseCompactComposer = isCommandComposerCompact && hasGeneratedCloneWork && !(isRecordDetailWorkspace && !isCloneConversationCollapsed);
const shouldShowScenarioScrollHint = !isRecordDetailWorkspace;
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
if (turn.settingLabel) return turn.settingLabel;
if (turn.output === "set" && turn.results?.length && !turn.setResultImages?.length) {
return `单图 ${turn.results.length}`;
}
if (turn.output === "set") {
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
return `套图 ${total || 1}`;
}
if (turn.output === "detail") return `详情 ${turn.detailModules?.length || 1}`;
if (turn.output === "model") return `模特 ${turn.modelScenes?.length || 1}`;
return cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
};
const recordCanvasGroups: RecordCanvasGroup[] = isRecordDetailWorkspace
? activeConversationTurns.reduce<RecordCanvasGroup[]>((groups, turn, index) => {
const turnResults = getTurnResults(turn);
if (!turnResults.length && turn.status !== "generating") return groups;
const canvasNode = canvasNodes.find((node) => node.id === turn.id);
const outputLabel = turn.modeLabel || cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
groups.push({
id: turn.id,
mode: turn.output,
outputLabel,
settingLabel: getHistoryTurnSettingLabel(turn),
sourceImages: turn.productImages,
results: turnResults,
createdAt: turn.createdAt,
turnIndex: index + 1,
status: turn.status,
x: canvasNode?.x ?? groups.length * 420,
y: canvasNode?.y ?? (groups.length % 2 === 0 ? 0 : 160),
});
return groups;
}, [])
: [];
useEffect(() => {
onWorkspaceChromeChange?.({ isToolPage: isWorkspaceToolPage });
@@ -5257,8 +5404,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleClonePlatformChange },
{ key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange },
{ key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage },
{ key: "market", label: "国家", value: market, options: cloneMarketOptions, onChange: handleCloneMarketChange },
{ key: "language", label: "语种", value: language, options: languageMarketOptions, onChange: handleCloneLanguageChange },
{ key: "ratio", label: "尺寸/比例", value: ratio, options: cloneRatioOptions, onChange: setRatio },
];
const quickDetailBasicSelects: Array<{
@@ -5269,8 +5416,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: detailPlatform, options: platformOptions, onChange: handleDetailPlatformChange },
{ key: "market", label: "国家", value: detailMarket, options: marketOptions, onChange: handleDetailMarketChange },
{ key: "language", label: "语种", value: detailLanguage, options: detailLanguageOptions, onChange: setDetailLanguage },
{ key: "market", label: "国家", value: detailMarket, options: detailMarketOptions, onChange: handleDetailMarketChange },
{ key: "language", label: "语种", value: detailLanguage, options: languageMarketOptions, onChange: handleDetailLanguageChange },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(detailRatio), options: quickSetRatioOptions, onChange: setDetailRatio },
];
@@ -5282,8 +5429,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: hotPlatform, options: platformOptions, onChange: handleHotPlatformChange },
{ key: "market", label: "国家", value: hotMarket, options: marketOptions, onChange: handleHotMarketChange },
{ key: "language", label: "语种", value: hotLanguage, options: hotLanguageOptions, onChange: setHotLanguage },
{ key: "market", label: "国家", value: hotMarket, options: hotMarketOptions, onChange: handleHotMarketChange },
{ key: "language", label: "语种", value: hotLanguage, options: languageMarketOptions, onChange: handleHotLanguageChange },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(hotRatio), options: quickSetRatioOptions, onChange: setHotRatio },
];
@@ -5295,8 +5442,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
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: "market", label: "国家", value: market, options: cloneMarketOptions, onChange: handleCloneMarketChange },
{ key: "language", label: "语种", value: language, options: languageMarketOptions, onChange: handleCloneLanguageChange },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(ratio), options: quickSetRatioOptions, onChange: setRatio },
];
@@ -6229,93 +6376,211 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
)
) : (
<>
{status === "done" || canvasNodes.length > 0 ? (
{status === "done" || canvasNodes.length > 0 || (isRecordDetailWorkspace && recordCanvasGroups.length > 0) ? (
<div className="clone-ai-preview-zoom-wrap" style={previewTransformStyle}>
<section className="clone-ai-canvas-nodes" aria-label="生成结果">
{canvasNodes.map((node) => (
<article
key={node.id}
className="clone-ai-canvas-node"
data-mode={node.mode}
data-node-id={node.id}
style={{ transform: `translate(${node.x}px, ${node.y}px)` }}
onPointerDown={(event) => startCanvasNodeDrag(event, node)}
onPointerMove={(event) => moveCanvasNodeDrag(event, node.id)}
onPointerUp={(event) => stopCanvasNodeDrag(event, node.id)}
onPointerCancel={(event) => stopCanvasNodeDrag(event, node.id)}
>
<div
className="clone-ai-node-drag-handle"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
nodeDragRef.current = { active: true, nodeId: node.id, startX: e.clientX, startY: e.clientY, originX: node.x, originY: node.y };
}}
onPointerMove={(e) => {
const drag = nodeDragRef.current;
if (!drag.active || drag.nodeId !== node.id) return;
const zoom = previewZoomRef.current;
const dx = (e.clientX - drag.startX) / zoom;
const dy = (e.clientY - drag.startY) / zoom;
setCanvasNodes((prev) => prev.map((n) => n.id === node.id ? { ...n, x: drag.originX + dx, y: drag.originY + dy } : n));
}}
onPointerUp={(e) => {
if (nodeDragRef.current.nodeId === node.id) {
nodeDragRef.current = { ...nodeDragRef.current, active: false };
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
/>
<div className="clone-ai-source-stack">
<button
type="button"
className="clone-ai-source-corner-action"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
</button>
<button
type="button"
className="clone-ai-main-result"
aria-label="预览原图素材"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
{node.sourceImage ? (
<img
src={node.sourceImage}
alt="原图素材"
onError={(event) => {
event.currentTarget.style.display = "none";
event.currentTarget.parentElement?.classList.add("is-missing-source");
{isRecordDetailWorkspace ? (
<section className="clone-ai-turn-groups" aria-label="按轮次生成结果">
{recordCanvasGroups.map((group) => {
const primarySource = group.sourceImages[0];
const dragNode: CanvasNode = {
id: group.id,
mode: group.mode,
sourceImage: primarySource?.src,
results: group.results,
createdAt: group.createdAt,
x: group.x,
y: group.y,
};
return (
<article
key={group.id}
className={`clone-ai-canvas-node clone-ai-turn-group is-${group.status}`}
data-mode={group.mode}
data-turn-id={group.id}
data-node-id={group.id}
style={{ transform: `translate(${group.x}px, ${group.y}px)` }}
onPointerDown={(event) => startCanvasNodeDrag(event, dragNode)}
onPointerMove={(event) => moveCanvasNodeDrag(event, group.id)}
onPointerUp={(event) => stopCanvasNodeDrag(event, group.id)}
onPointerCancel={(event) => stopCanvasNodeDrag(event, group.id)}
>
<div
className="clone-ai-node-drag-handle"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
nodeDragRef.current = { active: true, nodeId: group.id, startX: e.clientX, startY: e.clientY, originX: group.x, originY: group.y };
}}
onPointerMove={(e) => {
const drag = nodeDragRef.current;
if (!drag.active || drag.nodeId !== group.id) return;
const zoom = previewZoomRef.current;
const dx = (e.clientX - drag.startX) / zoom;
const dy = (e.clientY - drag.startY) / zoom;
setCanvasNodes((prev) => prev.map((n) => n.id === group.id ? { ...n, x: drag.originX + dx, y: drag.originY + dy } : n));
}}
onPointerUp={(e) => {
if (nodeDragRef.current.nodeId === group.id) {
nodeDragRef.current = { ...nodeDragRef.current, active: false };
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
/>
) : null}
<span className="clone-ai-source-missing"></span>
</button>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-stack">
<span className="clone-ai-node-label">{node.mode === "set" ? "套图" : node.mode === "detail" ? "详情图" : node.mode === "model" ? "模特图" : node.mode === "hot" ? "爆款图" : node.mode}</span>
<div className="clone-ai-result-grid result-reveal">
{node.results.map((card) => (
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card, { nodeId: node.id, removable: true })}>
<img src={card.src} alt={card.label} />
<div className={`clone-ai-turn-group__body${primarySource?.src ? "" : " is-without-source"}`}>
{primarySource?.src ? (
<>
<div className="clone-ai-source-stack">
<button
type="button"
className="clone-ai-source-corner-action"
onClick={() => openProductSetPreview({ src: primarySource.src, label: "原图素材" })}
>
</button>
<button
type="button"
className="clone-ai-main-result"
aria-label="预览原图素材"
onClick={() => openProductSetPreview({ src: primarySource.src, label: "原图素材" })}
>
<img
src={primarySource.src}
alt={primarySource.name || "原图素材"}
onError={(event) => {
event.currentTarget.style.display = "none";
event.currentTarget.parentElement?.classList.add("is-missing-source");
}}
/>
<span className="clone-ai-source-missing"></span>
</button>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
</>
) : null}
<div className="clone-ai-result-stack">
<span className="clone-ai-node-label">{group.outputLabel}</span>
<div className="clone-ai-result-grid result-reveal">
{group.results.map((card) => (
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card, { nodeId: group.id, removable: true })}>
<img src={card.src} alt={card.label} />
</button>
))}
{group.status === "generating" && !group.results.length ? (
<div className="clone-ai-turn-group__pending" aria-live="polite">
<LoadingOutlined />
<span>{group.outputLabel}...</span>
</div>
) : null}
</div>
</div>
</div>
</article>
);
})}
{status === "generating" && !recordCanvasGroups.some((group) => group.status === "generating") ? (
<article className="clone-ai-turn-group is-generating">
<header className="clone-ai-turn-group__head">
<div>
<span></span>
<strong>{selectedCloneOutput.label}</strong>
</div>
<small>AI </small>
</header>
<div className="clone-ai-turn-group__pending" aria-live="polite">
<LoadingOutlined />
<span>{selectedCloneOutput.label}...</span>
</div>
</article>
) : null}
</section>
) : (
<section className="clone-ai-canvas-nodes" aria-label="生成结果">
{canvasNodes.map((node) => (
<article
key={node.id}
className="clone-ai-canvas-node"
data-mode={node.mode}
data-node-id={node.id}
style={{ transform: `translate(${node.x}px, ${node.y}px)` }}
onPointerDown={(event) => startCanvasNodeDrag(event, node)}
onPointerMove={(event) => moveCanvasNodeDrag(event, node.id)}
onPointerUp={(event) => stopCanvasNodeDrag(event, node.id)}
onPointerCancel={(event) => stopCanvasNodeDrag(event, node.id)}
>
<div
className="clone-ai-node-drag-handle"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
nodeDragRef.current = { active: true, nodeId: node.id, startX: e.clientX, startY: e.clientY, originX: node.x, originY: node.y };
}}
onPointerMove={(e) => {
const drag = nodeDragRef.current;
if (!drag.active || drag.nodeId !== node.id) return;
const zoom = previewZoomRef.current;
const dx = (e.clientX - drag.startX) / zoom;
const dy = (e.clientY - drag.startY) / zoom;
setCanvasNodes((prev) => prev.map((n) => n.id === node.id ? { ...n, x: drag.originX + dx, y: drag.originY + dy } : n));
}}
onPointerUp={(e) => {
if (nodeDragRef.current.nodeId === node.id) {
nodeDragRef.current = { ...nodeDragRef.current, active: false };
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
/>
<div className="clone-ai-source-stack">
<button
type="button"
className="clone-ai-source-corner-action"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
</button>
))}
</div>
</div>
</article>
))}
{status === "generating" ? (
<article className="clone-ai-canvas-node is-generating" style={{ transform: `translate(${canvasNodes.length * 420}px, 0px)` }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<span>{selectedCloneOutput.label}</span>
</article>
) : null}
</section>
<button
type="button"
className="clone-ai-main-result"
aria-label="预览原图素材"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
{node.sourceImage ? (
<img
src={node.sourceImage}
alt="原图素材"
onError={(event) => {
event.currentTarget.style.display = "none";
event.currentTarget.parentElement?.classList.add("is-missing-source");
}}
/>
) : null}
<span className="clone-ai-source-missing"></span>
</button>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-stack">
<span className="clone-ai-node-label">{node.mode === "set" ? "套图" : node.mode === "detail" ? "详情图" : node.mode === "model" ? "模特图" : node.mode === "hot" ? "爆款图" : node.mode}</span>
<div className="clone-ai-result-grid result-reveal">
{node.results.map((card) => (
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card, { nodeId: node.id, removable: true })}>
<img src={card.src} alt={card.label} />
</button>
))}
</div>
</div>
</article>
))}
{status === "generating" ? (
<article className="clone-ai-canvas-node is-generating" style={{ transform: `translate(${canvasNodes.length * 420}px, 0px)` }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<span>{selectedCloneOutput.label}</span>
</article>
) : null}
</section>
)}
</div>
) : status === "idle" || status === "ready" ? null : (
<section className="clone-ai-empty-state" aria-live="polite">
@@ -6341,13 +6606,71 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section
ref={commandComposerWrapRef}
className={`clone-ai-bottom-input ecom-command-composer-wrap${status === "done" || canvasNodes.length > 0 ? " has-generated" : " is-before-generate"}${isCommandComposerCompact && (status === "done" || canvasNodes.length > 0) ? " is-compact" : ""}`}
aria-label="生成指令"
className={`clone-ai-bottom-input ecom-command-composer-wrap${hasGeneratedCloneWork ? " has-generated" : " is-before-generate"}${shouldUseCompactComposer ? " is-compact" : ""}${isRecordDetailWorkspace && isCloneConversationCollapsed ? " is-recall-entry" : ""}`}
aria-label={isRecordDetailWorkspace && isCloneConversationCollapsed ? "展开完整生成指令" : "生成指令"}
role={isRecordDetailWorkspace && isCloneConversationCollapsed ? "button" : undefined}
tabIndex={isRecordDetailWorkspace && isCloneConversationCollapsed ? 0 : undefined}
onClickCapture={(event) => {
if (isRecordDetailWorkspace && isCloneConversationCollapsed) {
if ((event.target as HTMLElement).closest(".ecom-command-recall__home")) return;
event.preventDefault();
event.stopPropagation();
setIsCloneConversationCollapsed(false);
window.setTimeout(() => requirementTextareaRef.current?.focus(), 180);
}
}}
onClick={() => {
if (isRecordDetailWorkspace && isCloneConversationCollapsed) {
setIsCloneConversationCollapsed(false);
window.setTimeout(() => requirementTextareaRef.current?.focus(), 180);
return;
}
if (isCommandComposerCompact) setIsCommandComposerCompact(false);
}}
onKeyDown={(event) => {
if (!isRecordDetailWorkspace || !isCloneConversationCollapsed) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setIsCloneConversationCollapsed(false);
window.setTimeout(() => requirementTextareaRef.current?.focus(), 180);
}}
>
<h1 className={`ecom-command-title${status === "done" || canvasNodes.length > 0 ? " is-after-generate" : ""}`}>
{isRecordDetailWorkspace && isCloneConversationCollapsed ? (
<div className="ecom-command-recall">
<span className="ecom-command-recall__media" aria-hidden="true">
{commandRecallAsset ? (
<img src={commandRecallAsset.src} alt="" />
) : (
<PaperClipOutlined />
)}
</span>
<span className="ecom-command-recall__content">
<span className="ecom-command-recall__eyebrow">
<em>{commandRecallStatus}</em>
</span>
<span className="ecom-command-recall__text">{commandRecallPrompt}</span>
</span>
<button
type="button"
className="ecom-command-recall__home"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleNewEcommerceConversation();
}}
aria-label="返回首页"
title="返回首页"
>
<AppstoreOutlined />
<span></span>
</button>
<span className="ecom-command-recall__action" aria-hidden="true">
<span></span>
<MenuUnfoldOutlined />
</span>
</div>
) : null}
<h1 className={`ecom-command-title${hasGeneratedCloneWork ? " is-after-generate" : ""}`}>
{typewriterText}
<span className="typewriter-cursor" aria-hidden="true">|</span>
</h1>
@@ -7127,6 +7450,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</aside>
<section className="ecom-image-workbench-stage">
<header className="ecom-visual-workspace-head ecom-copywriting-preview-head">
<h1></h1>
<p><span>AI</span> </p>
</header>
{!imageWorkbenchImage ? (
<div
className={`ecom-watermark-dropzone${isImageWorkbenchDragging ? " is-dragging" : ""}`}
@@ -7385,6 +7712,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</aside>
<section className="ecom-watermark-workspace">
<header className="ecom-visual-workspace-head ecom-copywriting-preview-head">
<h1></h1>
<p><span>AI</span> </p>
</header>
{!translateImage ? (
<div
className={`ecom-watermark-dropzone${isTranslateDragging ? " is-dragging" : ""}`}
@@ -7514,7 +7845,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload${detailProductImages.length ? " has-images" : ""}`}
className={`ecom-quick-set-upload ecom-quick-hot-material${detailProductImages.length ? " has-images" : ""}`}
onClick={() => detailInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, detailInputRef)}
onDragOver={(event) => event.preventDefault()}
@@ -7524,7 +7855,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<span></span>
<em> 3 </em>
<b>+ </b>
{detailProductImages.length ? renderQuickUploadThumbs(detailProductImages, removeDetailImage) : null}
{detailProductImages.length ? (
<>
{renderHotMaterialThumbs(detailProductImages, removeDetailImage)}
{detailProductImages.length < 3 ? (
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="Add more detail images"
onClick={(event) => {
event.stopPropagation();
detailInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
) : null}
</>
) : null}
</div>
<input
ref={detailInputRef}
@@ -7610,6 +7958,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<button type="button" className="ecom-quick-set-primary ecom-quick-set-primary--cancel" onClick={handleCancelGenerate}></button>
) : null}
</aside>
{hotMaterialHoverZoom && typeof document !== "undefined"
? createPortal(
<div
className={`ecom-hot-material-zoom-portal is-${hotMaterialHoverZoom.placement}`}
style={{ left: hotMaterialHoverZoom.x, top: hotMaterialHoverZoom.y }}
>
<img src={hotMaterialHoverZoom.src} alt="" />
</div>,
document.body,
)
: null}
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
@@ -7978,7 +8337,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
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)}
{renderHotMaterialThumbs(productImages, removeProductImage)}
<button
type="button"
className="ecom-quick-hot-add-btn"
@@ -8137,6 +8496,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</button>
</div>
</aside>
{hotMaterialHoverZoom && typeof document !== "undefined"
? createPortal(
<div
className={`ecom-hot-material-zoom-portal is-${hotMaterialHoverZoom.placement}`}
style={{ left: hotMaterialHoverZoom.x, top: hotMaterialHoverZoom.y }}
>
<img src={hotMaterialHoverZoom.src} alt="" />
</div>,
document.body,
)
: null}
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
@@ -8311,11 +8681,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</main>
);
const copywritingPreview = (
<div key="copywriting" className="ecom-quick-page-wrap ecom-tool-page-enter">
<EcommerceCopywritingPanel onClose={closeCopywritingPage} />
</div>
);
const copywritingPreview = <EcommerceCopywritingPanel onClose={closeCopywritingPage} />;
const oneClickVideoPreview = (
<div key="oneClickVideo" className="ecom-quick-page-wrap ecom-tool-page-enter">
@@ -8361,59 +8727,66 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
? tryOnPreview
: isCloneTool
? isWatermarkTool
? watermarkPreview
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("watermark", "visual")}
{watermarkPreview}
</div>
)
: isTranslateTool
? translatePreview
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("translate", "visual")}
{translatePreview}
</div>
)
: isImageEditTool
? imageWorkbenchPreview
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("image-edit", "visual")}
{imageWorkbenchPreview}
</div>
)
: isSmartCutoutTool
? smartCutoutPreview
: isQuickDetailTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("detail")}
{quickDetailPreview}
</div>
)
: isHotCloneTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("hot")}
{hotClonePreview}
</div>
)
: isQuickSetTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("quick-set")}
{quickSetGenPreview}
</div>
)
: isCopywritingTool
? copywritingPreview
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("copywriting", "visual")}
{copywritingPreview}
</div>
)
: isOneClickVideoTool
? oneClickVideoPreview
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-one-click-video-wrap ecom-tool-page-enter">
{renderQuickPageSidebar("oneClickVideo")}
{oneClickVideoPreview}
</div>
)
: clonePreview
: placeholderPreview;
const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isQuickSetTool && !isCopywritingTool && !isOneClickVideoTool;
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
const activeConversationTurns = activeHistoryRecord
? activeHistoryRecord.turns?.length
? activeHistoryRecord.turns
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
: [];
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
if (turn.settingLabel) return turn.settingLabel;
if (turn.output === "set" && turn.results?.length && !turn.setResultImages?.length) {
return `单图 ${turn.results.length}`;
}
if (turn.output === "set") {
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
return `套图 ${total || 1}`;
}
if (turn.output === "detail") return `详情 ${turn.detailModules?.length || 1}`;
if (turn.output === "model") return `模特 ${turn.modelScenes?.length || 1}`;
return cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
};
const restoreHistoryTurnInputs = (turn: EcommerceHistoryTurn) => {
setCloneOutput(turn.output);
setPlatform(turn.platform);
@@ -8474,18 +8847,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<>
<aside className="clone-ai-conversation-panel" aria-label="AI 对话">
<header className="clone-ai-conversation-head">
<div>
<div className="clone-ai-conversation-title">
<strong>{activeHistoryRecord?.title || "生成详情"}</strong>
<span>{selectedCloneOutput.label} · {platform} · {language}</span>
</div>
<button
type="button"
onClick={() => setIsCloneConversationCollapsed(true)}
aria-label="收起对话"
title="收起对话"
>
<MenuFoldOutlined />
</button>
<div className="clone-ai-conversation-actions">
<button
type="button"
className="clone-ai-conversation-home"
onClick={handleNewEcommerceConversation}
aria-label="返回首页"
title="返回首页"
>
<AppstoreOutlined />
<span></span>
</button>
<button
type="button"
className="clone-ai-conversation-collapse"
onClick={() => setIsCloneConversationCollapsed(true)}
aria-label="收起对话"
title="收起对话"
>
<MenuFoldOutlined />
</button>
</div>
</header>
<div className="clone-ai-conversation-body">
{activeConversationTurns.map((turn, index) => {
@@ -8557,16 +8942,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
})}
</div>
</aside>
<button
type="button"
className="clone-ai-conversation-toggle"
onClick={() => setIsCloneConversationCollapsed((current) => !current)}
aria-label={isCloneConversationCollapsed ? "展开对话" : "收起对话"}
title={isCloneConversationCollapsed ? "展开对话" : "收起对话"}
aria-expanded={!isCloneConversationCollapsed}
>
{isCloneConversationCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
</>
) : null}
@@ -75,7 +75,7 @@ export default function CommandHistorySidebar({
</div>
<div className="ecom-command-history__heading">
<strong></strong>
<span>{records.length} </span>
<span className="ecom-command-history__count">{records.length} </span>
</div>
{refreshMessage ? (
<p key={refreshStamp} className="ecom-command-history__refresh-note" role="status">
@@ -86,13 +86,25 @@ export default function CommandHistorySidebar({
{records.length ? (
records.map((record) => {
const outputLabel = outputLabels.find((option) => option.key === record.output)?.label || "生成记录";
const statusKey = record.status === "generating" ? "generating" : record.status === "failed" ? "failed" : "done";
const statusLabel =
record.status === "generating" ? "生成中" : record.status === "failed" ? "失败" : formatHistoryTime(record.createdAt);
return (
<div key={`${record.id}-${refreshTick}`} className={`ecom-command-history__item${activeRecordId === record.id ? " is-active" : ""}`}>
<button type="button" className="ecom-command-history__item-main" onClick={() => onOpenRecord(record)}>
<div
key={`${record.id}-${refreshTick}`}
className={`ecom-command-history__item is-${statusKey}${activeRecordId === record.id ? " is-active" : ""}`}
>
<button
type="button"
className={`ecom-command-history__item-main${activeRecordId === record.id ? " is-active" : ""}`}
onClick={() => onOpenRecord(record)}
aria-current={activeRecordId === record.id ? "page" : undefined}
>
<strong>{record.title}</strong>
<span>{outputLabel} · {statusLabel}</span>
<span className="ecom-command-history__item-meta">
<span>{outputLabel}</span>
<em>{statusLabel}</em>
</span>
</button>
<button
type="button"
@@ -4,7 +4,8 @@ import {
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useMemo, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent, type RefObject } from "react";
import { useMemo, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
import { createPortal } from "react-dom";
import EcommerceVideoWorkspace from "../EcommerceVideoWorkspace";
interface CloneImageItem {
@@ -97,6 +98,7 @@ export default function EcommerceOneClickVideoPanel({
}: EcommerceOneClickVideoPanelProps) {
const [openSelect, setOpenSelect] = useState<"platform" | "ratio" | null>(null);
const [planTrigger, setPlanTrigger] = useState(0);
const [hoverZoom, setHoverZoom] = useState<{ src: string; x: number; y: number; placement: "right" | "left" } | null>(null);
const selectAnchorRef = useRef<HTMLDivElement>(null);
const productImageDataUrls = useMemo(() => productImages.map((img) => img.src), [productImages]);
@@ -126,19 +128,40 @@ export default function EcommerceOneClickVideoPanel({
setOpenSelect((current) => (current === key ? null : key));
};
const handleThumbMouseEnter = (src: string, event: ReactMouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const previewWidth = 300;
const previewHeight = 190;
const gap = 12;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const canShowRight = rect.right + gap + previewWidth <= viewportWidth - gap;
const placement: "right" | "left" = canShowRight ? "right" : "left";
const x = placement === "right" ? rect.right + gap : Math.max(gap, rect.left - gap);
const y = Math.min(
Math.max(rect.top + rect.height / 2, previewHeight / 2 + gap),
Math.max(previewHeight / 2 + gap, viewportHeight - previewHeight / 2 - gap),
);
setHoverZoom({ src, x, y, placement });
};
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">
<figure
key={item.id}
className="ecom-command-asset-thumb ecom-quick-upload-thumb"
onMouseEnter={(event) => handleThumbMouseEnter(item.src, event)}
onMouseLeave={() => setHoverZoom(null)}
>
<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"
className="ecom-hot-material-delete"
aria-label="删除图片"
onClick={(event) => {
event.stopPropagation();
setHoverZoom(null);
removeProductImage(item.id);
}}
>
@@ -386,6 +409,17 @@ export default function EcommerceOneClickVideoPanel({
</button>
</div>
</aside>
{hoverZoom && typeof document !== "undefined"
? createPortal(
<div
className={`ecom-hot-material-zoom-portal is-${hoverZoom.placement}`}
style={{ left: hoverZoom.x, top: hoverZoom.y }}
>
<img src={hoverZoom.src} alt="" />
</div>,
document.body,
)
: null}
<section className="ecom-quick-set-stage">
<EcommerceVideoWorkspace
@@ -155,6 +155,10 @@ export default function WatermarkToolPage({
</aside>
<section className="ecom-watermark-workspace">
<header className="ecom-visual-workspace-head ecom-copywriting-preview-head">
<h1></h1>
<p><span>AI</span> </p>
</header>
{!image ? (
<div
className={`ecom-watermark-dropzone${isDragging ? " is-dragging" : ""}`}
File diff suppressed because it is too large Load Diff