feat(canvas): 多图批次 - 子图操作条补齐(下载/副本/设主图/失败重试删除)

- 展开批次子图悬浮操作条:成功子项下载/创建副本/设为主图,失败子项重试/删除
- 批次主图位常驻下载按钮
- 通过 CanvasNodeActionContext 注入画布级动作(download/duplicate/delete),不改 world-layers 数据模型
This commit is contained in:
dlh
2026-08-20 19:46:18 +08:00
parent c0e017d006
commit 55b35dd943
3 changed files with 67 additions and 1 deletions

View File

@@ -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<CanvasNodeActionContextValue>({});
export function useCanvasNodeActions() {
return useContext(CanvasNodeActionContext);
}

View File

@@ -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 ? <NodeLockBadge theme={theme} /> : null}
</div>
) : null}
{/* 批次子图操作条:成功子项提供下载/副本/设为主图,失败子项提供重试/删除 */}
{isBatchChild && !readOnly && (hasImageContent || data.metadata?.status === "error") && (hovered || isSelected) ? (
<div
className="absolute inset-x-0 bottom-2 z-[var(--node-z-overlay)] flex justify-center"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-0.5 rounded-[var(--r-md)] border px-1 py-1 backdrop-blur-xl" style={{ background: `${theme.toolbar.panel}e6`, borderColor: theme.toolbar.border }}>
{hasImageContent ? <BatchChildActionButton theme={theme} label="下载图片" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} /> : null}
{hasImageContent ? <BatchChildActionButton theme={theme} label="创建副本" icon={<Copy className="size-3.5" />} onClick={() => duplicateNode?.(data)} /> : null}
{hasImageContent ? (
<BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} />
) : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="重试" icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="删除" icon={<Trash2 className="size-3.5" />} danger onClick={() => deleteNode?.(data)} /> : null}
</div>
</div>
) : null}
{/* 批次主图位(折叠根节点封面)常驻下载按钮 */}
{isBatchRoot && hasImageContent && !readOnly ? (
<div className="absolute bottom-2 right-2 z-[var(--node-z-overlay)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<BatchChildActionButton theme={theme} label="下载主图" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} />
</div>
) : null}
{assetTags.length || (showImageInfo && hasImageContent) ? (
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : 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 (
<button
type="button"
data-canvas-no-zoom
className="canvas-node-inline-action grid size-8 place-items-center rounded-[var(--r-sm)] p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ color: danger ? theme.accent.danger : theme.node.text, outlineColor: theme.accent.primary }}
title={label}
aria-label={label}
onClick={(event) => { event.stopPropagation(); onClick(); }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{icon}
</button>
);
}
function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1">

View File

@@ -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}
>
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])) }}>
<CanvasProjectWorldLayers
projectId={projectId}
viewportScale={viewport.k}
@@ -1847,6 +1849,7 @@ function InfiniteCanvasPage() {
onOpenDrawing={openDrawingNode}
onStartBatchConnection={startBatchConnection}
/>
</CanvasNodeActionContext.Provider>
</InfiniteCanvas>
<CanvasActiveTaskPanel tasks={activeTasks} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />