feat: 记录详情页Canvas按轮次分组展示,对话面板UI重构与视觉升级
CI / verify (pull_request) Waiting to run

- Canvas视图重构:记录详情页用 turn-groups 按生成轮次分组替代原 canvas-nodes,支持生成中/失败状态展示及拖拽定位
- 对话面板头部改为 title + actions 布局,新增首页返回按钮,移除独立 toggle
- 对话收起时显示 recall 入口卡片,展示当前轮次摘要信息并支持一键展开继续对话
- 历史记录侧边栏列表项新增状态 class(is-generating/is-failed/is-done),元信息拆分为类型+状态标签结构
- CSS 新增 1772 行:记录详情页整体视觉升级(渐变背景、毛玻璃面板、胶囊卡片、状态色条),Canvas 节点响应式布局,Preview modal 底部操作栏美化
This commit is contained in:
2026-06-18 18:28:57 +08:00
parent 207f05ac86
commit 7795ca3cbb
3 changed files with 2150 additions and 134 deletions
+269 -37
View File
@@ -305,6 +305,20 @@ interface CanvasNode {
y: number; 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 { interface PreviewTouchPoint {
id: number; id: number;
x: number; x: number;
@@ -4695,6 +4709,65 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
isOneClickVideoTool || isOneClickVideoTool ||
isVideoWorkspaceVisible || isVideoWorkspaceVisible ||
Boolean(activeHistoryRecordId); 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(() => { useEffect(() => {
onWorkspaceChromeChange?.({ isToolPage: isWorkspaceToolPage }); onWorkspaceChromeChange?.({ isToolPage: isWorkspaceToolPage });
@@ -6109,8 +6182,125 @@ 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}> <div className="clone-ai-preview-zoom-wrap" style={previewTransformStyle}>
{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);
}
}}
/>
<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="生成结果"> <section className="clone-ai-canvas-nodes" aria-label="生成结果">
{canvasNodes.map((node) => ( {canvasNodes.map((node) => (
<article <article
@@ -6196,6 +6386,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</article> </article>
) : null} ) : null}
</section> </section>
)}
</div> </div>
) : status === "idle" || status === "ready" ? null : ( ) : status === "idle" || status === "ready" ? null : (
<section className="clone-ai-empty-state" aria-live="polite"> <section className="clone-ai-empty-state" aria-live="polite">
@@ -6221,13 +6412,71 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section <section
ref={commandComposerWrapRef} 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" : ""}`} 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="生成指令" 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={() => { onClick={() => {
if (isRecordDetailWorkspace && isCloneConversationCollapsed) {
setIsCloneConversationCollapsed(false);
window.setTimeout(() => requirementTextareaRef.current?.focus(), 180);
return;
}
if (isCommandComposerCompact) setIsCommandComposerCompact(false); 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} {typewriterText}
<span className="typewriter-cursor" aria-hidden="true">|</span> <span className="typewriter-cursor" aria-hidden="true">|</span>
</h1> </h1>
@@ -6287,9 +6536,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</button> </button>
</div> </div>
</div> </div>
{shouldShowScenarioScrollHint ? (
<span className="ecom-command-scenario-scroll-hint" aria-hidden="true"> <span className="ecom-command-scenario-scroll-hint" aria-hidden="true">
{isCommerceScenarioMoreOpen ? "左右滑动查看全部场景" : "点击更多查看全部场景"} {isCommerceScenarioMoreOpen ? "左右滑动查看全部场景" : "点击更多查看全部场景"}
</span> </span>
) : null}
<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}`}>
@@ -8268,28 +8519,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
? oneClickVideoPreview ? oneClickVideoPreview
: clonePreview : clonePreview
: placeholderPreview; : 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 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) => { const restoreHistoryTurnInputs = (turn: EcommerceHistoryTurn) => {
setCloneOutput(turn.output); setCloneOutput(turn.output);
setPlatform(turn.platform); setPlatform(turn.platform);
@@ -8350,18 +8580,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<> <>
<aside className="clone-ai-conversation-panel" aria-label="AI 对话"> <aside className="clone-ai-conversation-panel" aria-label="AI 对话">
<header className="clone-ai-conversation-head"> <header className="clone-ai-conversation-head">
<div> <div className="clone-ai-conversation-title">
<strong>{activeHistoryRecord?.title || "生成详情"}</strong> <strong>{activeHistoryRecord?.title || "生成详情"}</strong>
<span>{selectedCloneOutput.label} · {platform} · {language}</span>
</div> </div>
<div className="clone-ai-conversation-actions">
<button <button
type="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)} onClick={() => setIsCloneConversationCollapsed(true)}
aria-label="收起对话" aria-label="收起对话"
title="收起对话" title="收起对话"
> >
<MenuFoldOutlined /> <MenuFoldOutlined />
</button> </button>
</div>
</header> </header>
<div className="clone-ai-conversation-body"> <div className="clone-ai-conversation-body">
{activeConversationTurns.map((turn, index) => { {activeConversationTurns.map((turn, index) => {
@@ -8433,16 +8675,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
})} })}
</div> </div>
</aside> </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} ) : null}
@@ -75,7 +75,7 @@ export default function CommandHistorySidebar({
</div> </div>
<div className="ecom-command-history__heading"> <div className="ecom-command-history__heading">
<strong></strong> <strong></strong>
<span>{records.length} </span> <span className="ecom-command-history__count">{records.length} </span>
</div> </div>
{refreshMessage ? ( {refreshMessage ? (
<p key={refreshStamp} className="ecom-command-history__refresh-note" role="status"> <p key={refreshStamp} className="ecom-command-history__refresh-note" role="status">
@@ -86,13 +86,25 @@ export default function CommandHistorySidebar({
{records.length ? ( {records.length ? (
records.map((record) => { records.map((record) => {
const outputLabel = outputLabels.find((option) => option.key === record.output)?.label || "生成记录"; const outputLabel = outputLabels.find((option) => option.key === record.output)?.label || "生成记录";
const statusKey = record.status === "generating" ? "generating" : record.status === "failed" ? "failed" : "done";
const statusLabel = const statusLabel =
record.status === "generating" ? "生成中" : record.status === "failed" ? "失败" : formatHistoryTime(record.createdAt); record.status === "generating" ? "生成中" : record.status === "failed" ? "失败" : formatHistoryTime(record.createdAt);
return ( return (
<div key={`${record.id}-${refreshTick}`} className={`ecom-command-history__item${activeRecordId === record.id ? " is-active" : ""}`}> <div
<button type="button" className="ecom-command-history__item-main" onClick={() => onOpenRecord(record)}> 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> <strong>{record.title}</strong>
<span>{outputLabel} · {statusLabel}</span> <span className="ecom-command-history__item-meta">
<span>{outputLabel}</span>
<em>{statusLabel}</em>
</span>
</button> </button>
<button <button
type="button" type="button"
File diff suppressed because it is too large Load Diff