From f9accd3befd6f0e5910fbde1dbe08f76ba0dc07a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 7 Aug 2026 16:57:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=96=87=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20-=20=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E4=B8=8E=E6=8B=96=E6=8B=BD=E4=B8=8A=E4=BC=A0=E5=BC=B9?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #169 --- .../components/canvas/canvas-upload-modal.tsx | 114 ++++++++++++++++++ web/src/pages/canvas/project.tsx | 6 + web/src/pages/canvas/use-canvas-upload.ts | 57 ++++++++- 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 web/src/components/canvas/canvas-upload-modal.tsx diff --git a/web/src/components/canvas/canvas-upload-modal.tsx b/web/src/components/canvas/canvas-upload-modal.tsx new file mode 100644 index 000000000..56bfe1c94 --- /dev/null +++ b/web/src/components/canvas/canvas-upload-modal.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { App, Button, Modal, Upload, type UploadFile } from "antd"; +import { FileImage, Film, Music2, UploadCloud } from "lucide-react"; + +import { isAudioFile } from "@/lib/canvas/canvas-project-generation"; + +const CANVAS_UPLOAD_ACCEPT = "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"; + +type CanvasUploadModalProps = { + open: boolean; + onClose: () => void; + onUpload: (files: File[]) => Promise; +}; + +export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModalProps) { + const { message } = App.useApp(); + const [fileList, setFileList] = useState([]); + const [uploading, setUploading] = useState(false); + + useEffect(() => { + if (!open) { + setFileList([]); + setUploading(false); + } + }, [open]); + + const submit = async () => { + const files = fileList.flatMap((item) => item.originFileObj ? [item.originFileObj] : []); + if (!files.length) return; + setUploading(true); + try { + if (await onUpload(files)) onClose(); + } catch (error) { + message.error(error instanceof Error ? error.message : "文件上传失败,请稍后重试"); + } finally { + setUploading(false); + } + }; + + return ( + +
+
+
+
上传文件
+
批量导入图片、视频和音频到当前画布
+
+ 已选 {fileList.length} 项 +
+ +
+ { + if (isCanvasUploadFile(file)) return false; + message.warning(`“${file.name}”不是支持的图片、视频或音频文件`); + return Upload.LIST_IGNORE; + }} + onChange={({ fileList: nextFileList }) => setFileList(nextFileList)} + onRemove={() => !uploading} + showUploadList={{ showPreviewIcon: false, showDownloadIcon: false, showRemoveIcon: !uploading }} + classNames={{ list: "thin-scrollbar" }} + styles={{ + root: { display: "block", width: "100%" }, + trigger: { borderColor: "var(--border)", borderRadius: "var(--r-lg)", background: "var(--workspace-surface)" }, + list: { maxHeight: 184, overflowY: "auto" }, + }} + > +
+ + +

拖动文件到这里,或点击选择

+

支持同时选择多个文件

+
+ + + +
+
+
+
+ +
+ 文件将在确认后按顺序添加到画布 +
+ + +
+
+
+
+ ); +} + +function isCanvasUploadFile(file: File) { + return file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index fa7b82905..877308aac 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -35,6 +35,7 @@ import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-mo import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal"; import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal"; import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay"; +import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal"; import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; import { Minimap } from "@/components/canvas/canvas-mini-map"; import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; @@ -431,6 +432,7 @@ function InfiniteCanvasPage() { const { assetPickerOpen, + closeUploadModal, closeAssetPicker, createImageAssetNode, fileDropActive, @@ -442,12 +444,14 @@ function InfiniteCanvasPage() { handleImageInputChange, handleProjectAssetsInsert, handleProjectChapterInsert, + handleUploadFiles, handleUploadRequest, imageInputRef, openAssetsAtPosition, pasteAssistantImage, pasteSystemClipboard, startUploadStatus, + uploadModalOpen, uploadStatus, } = useCanvasUpload({ canvasId: projectId, @@ -1813,6 +1817,8 @@ function InfiniteCanvasPage() { onToggleFrame={(node) => toggleFrameCollapsed(node.id)} /> + + setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} /> diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index 36506be46..72d40e11e 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -40,6 +40,9 @@ export type StartCanvasUploadStatus = (title: string, detail: string, total?: nu }; const NODE_STATUS_SUCCESS = "success" as const; +const BATCH_UPLOAD_COLUMNS = 3; +const BATCH_UPLOAD_COLUMN_GAP = 380; +const BATCH_UPLOAD_ROW_GAP = 300; export function useCanvasUpload({ canvasId, @@ -63,6 +66,7 @@ export function useCanvasUpload({ const statusTimersRef = useRef>(new Set()); const fileDragDepthRef = useRef(0); const [assetPickerOpen, setAssetPickerOpen] = useState(false); + const [uploadModalOpen, setUploadModalOpen] = useState(false); const [uploadStatus, setUploadStatus] = useState(null); const [fileDropActive, setFileDropActive] = useState(false); @@ -133,12 +137,12 @@ export function useCanvasUpload({ if (domainProjectId) progress.update("写入项目资产", 4); const persisted = await persistMediaNode(node); progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试"); - return true; + return id; } catch (error) { const details = error instanceof Error ? error.message : "图片上传失败"; progress.fail(details); message.error(details); - return false; + return null; } }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); @@ -193,10 +197,12 @@ export function useCanvasUpload({ if (domainProjectId) progress.update("写入项目资产", 4); const persisted = await persistMediaNode(node); progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试"); + return id; } catch (error) { const details = error instanceof Error ? error.message : "视频上传失败"; progress.fail(details); message.error(details); + return null; } }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); @@ -214,10 +220,12 @@ export function useCanvasUpload({ if (domainProjectId) progress.update("写入项目资产", 4); const persisted = await persistMediaNode(node); progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试"); + return id; } catch (error) { const details = error instanceof Error ? error.message : "音频上传失败"; progress.fail(details); message.error(details); + return null; } }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); @@ -267,6 +275,10 @@ export function useCanvasUpload({ const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => { uploadTargetRef.current = { nodeId, position }; + if (!nodeId) { + setUploadModalOpen(true); + return; + } const target = nodeId ? nodesRef.current.find((node) => node.id === nodeId) : null; if (imageInputRef.current) { imageInputRef.current.accept = target?.type === CanvasNodeType.Image @@ -280,6 +292,44 @@ export function useCanvasUpload({ imageInputRef.current?.click(); }, [nodesRef]); + const closeUploadModal = useCallback(() => { + uploadTargetRef.current = null; + setUploadModalOpen(false); + }, []); + + const handleUploadFiles = useCallback(async (files: File[]) => { + const supportedFiles = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file)); + if (!supportedFiles.length) { + message.warning("请选择图片、视频、MP3 或 WAV 文件"); + return false; + } + const center = uploadTargetRef.current?.position || getCanvasCenter(); + const columns = Math.min(BATCH_UPLOAD_COLUMNS, supportedFiles.length); + const originX = center.x - ((columns - 1) * BATCH_UPLOAD_COLUMN_GAP) / 2; + const createdIds: string[] = []; + for (let index = 0; index < supportedFiles.length; index += 1) { + const file = supportedFiles[index]; + const position = { + x: originX + (index % columns) * BATCH_UPLOAD_COLUMN_GAP, + y: center.y + Math.floor(index / columns) * BATCH_UPLOAD_ROW_GAP, + }; + const createdId = await (isAudioFile(file) + ? createAudioFileNode(file, position) + : file.type.startsWith("video/") + ? createVideoFileNode(file, position) + : createImageFileNode(file, position)); + if (createdId) createdIds.push(createdId); + } + if (!createdIds.length) return false; + setSelectedNodeIds(new Set(createdIds)); + setSelectedConnectionId(null); + setDialogNodeId(null); + const failedCount = supportedFiles.length - createdIds.length; + if (failedCount) message.warning(`已添加 ${createdIds.length} 个文件,${failedCount} 个上传失败`); + else message.success(`已添加 ${createdIds.length} 个文件到画布`); + return true; + }, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]); + const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => { const currentNode = nodesRef.current.find((node) => node.id === nodeId); if (!currentNode) return false; @@ -626,12 +676,15 @@ export function useCanvasUpload({ handleImageInputChange, handleProjectAssetsInsert, handleProjectChapterInsert, + handleUploadFiles, handleUploadRequest, imageInputRef, + closeUploadModal, openAssetsAtPosition, pasteAssistantImage, pasteSystemClipboard, startUploadStatus, + uploadModalOpen, uploadStatus, }; }