From 55b35dd943a1bd3a01fc7d6b9a4790e461288c15 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Thu, 20 Aug 2026 19:46:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E5=A4=9A=E5=9B=BE=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=20-=20=E5=AD=90=E5=9B=BE=E6=93=8D=E4=BD=9C=E6=9D=A1?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=EF=BC=88=E4=B8=8B=E8=BD=BD/=E5=89=AF?= =?UTF-8?q?=E6=9C=AC/=E8=AE=BE=E4=B8=BB=E5=9B=BE/=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E5=88=A0=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 展开批次子图悬浮操作条:成功子项下载/创建副本/设为主图,失败子项重试/删除 - 批次主图位常驻下载按钮 - 通过 CanvasNodeActionContext 注入画布级动作(download/duplicate/delete),不改 world-layers 数据模型 --- .../canvas/canvas-node-action-context.ts | 18 +++++++ web/src/components/canvas/canvas-node.tsx | 47 ++++++++++++++++++- web/src/pages/canvas/project.tsx | 3 ++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 web/src/components/canvas/canvas-node-action-context.ts diff --git a/web/src/components/canvas/canvas-node-action-context.ts b/web/src/components/canvas/canvas-node-action-context.ts new file mode 100644 index 000000000..4a5fe7704 --- /dev/null +++ b/web/src/components/canvas/canvas-node-action-context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +import type { CanvasNodeData } from "@/types/canvas"; + +// 批次子图操作条(下载/创建副本/删除)与主图位下载需要调用画布级动作, +// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler, +// 通过 Context 注入,避免改动 world-layers。无 Provider 时静默降级为 no-op。 +export type CanvasNodeActionContextValue = { + download?: (node: CanvasNodeData) => void; + duplicate?: (node: CanvasNodeData) => void; + deleteNode?: (node: CanvasNodeData) => void; +}; + +export const CanvasNodeActionContext = createContext({}); + +export function useCanvasNodeActions() { + return useContext(CanvasNodeActionContext); +} diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 34c0c53db..fc936d5bb 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react"; +import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Replace, Settings2, Star, Trash2, Type, Upload, Video } from "lucide-react"; + +import { useCanvasNodeActions } from "./canvas-node-action-context"; import { canvasThemes } from "@/lib/canvas-theme"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; @@ -106,6 +108,7 @@ export const CanvasNode = React.memo(function CanvasNode({ const [isEditingContent, setIsEditingContent] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(data.title); + const { download: downloadNode, duplicate: duplicateNode, deleteNode } = useCanvasNodeActions(); const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content); const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content); const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content); @@ -428,6 +431,30 @@ export const CanvasNode = React.memo(function CanvasNode({ {data.metadata?.locked ? : null} ) : null} + {/* 批次子图操作条:成功子项提供下载/副本/设为主图,失败子项提供重试/删除 */} + {isBatchChild && !readOnly && (hasImageContent || data.metadata?.status === "error") && (hovered || isSelected) ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > +
+ {hasImageContent ? } onClick={() => downloadNode?.(data)} /> : null} + {hasImageContent ? } onClick={() => duplicateNode?.(data)} /> : null} + {hasImageContent ? ( + } onClick={() => onSetBatchPrimary?.(data)} /> + ) : null} + {data.metadata?.status === "error" ? } onClick={() => onRetry?.(data)} /> : null} + {data.metadata?.status === "error" ? } danger onClick={() => deleteNode?.(data)} /> : null} +
+
+ ) : null} + {/* 批次主图位(折叠根节点封面)常驻下载按钮 */} + {isBatchRoot && hasImageContent && !readOnly ? ( +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + } onClick={() => downloadNode?.(data)} /> +
+ ) : null} {assetTags.length || (showImageInfo && hasImageContent) ? (
{assetTags.length ? : null} @@ -539,6 +566,24 @@ function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: bo ); } +function BatchChildActionButton({ theme, label, icon, onClick, danger = false }: { theme: CanvasTheme; label: string; icon: ReactNode; onClick: () => void; danger?: boolean }) { + return ( + + ); +} + function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 0c13e72d6..e29ad4041 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -80,6 +80,7 @@ import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar"; import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs"; import { CanvasProjectWorldLayers } from "./canvas-project-world-layers"; +import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context"; import { CanvasRefreshShell } from "./canvas-refresh-shell"; import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel"; import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace"; @@ -1779,6 +1780,7 @@ function InfiniteCanvasPage() { onFileDragLeave={handleFileDragLeave} onFileDragOver={handleFileDragOver} > + duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])) }}> +