From bb4f51d198214d27661d1deb9fbadd5ceed8a9f0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 3 Feb 2026 16:33:51 +0800 Subject: [PATCH] feat: improve canvas workflow and auto title Hide internal prompts from users, auto-focus new images, avoid overlap, enable drag-and-drop upload, and fix canvas navigation reloads. --- app/(chat)/api/chat/route.ts | 67 ++++++---- app/(chat)/api/chat/schema.ts | 1 + app/api/canvas/auto-title/route.ts | 62 +++++----- components/canvas/CanvasChatPanel.tsx | 36 ++++-- components/canvas/CanvasFileListPanel.tsx | 9 +- components/canvas/CanvasWorkspace.tsx | 144 ++++++++++++++++++++++ components/canvas/nodes/ImageNode.tsx | 52 ++++++-- store/canvas/index.ts | 62 ++++++++++ 8 files changed, 350 insertions(+), 83 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 69007f0..17ed0bb 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -104,6 +104,26 @@ function hasAnnotatedImage(messages: ChatMessage[]): boolean { */ const ANNOTATION_SYSTEM_PROMPT = "【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。"; +function applyHiddenPromptToMessages(messages: ChatMessage[], hiddenPrompt?: string): ChatMessage[] { + if (!hiddenPrompt || !hiddenPrompt.trim()) return messages; + const cloned = messages.map((msg) => ({ + ...msg, + parts: msg.parts.map((part) => ({ ...part })), + })); + for (let i = cloned.length - 1; i >= 0; i--) { + const msg = cloned[i]; + if (msg.role !== "user") continue; + const textPart = msg.parts.find((part) => part.type === "text") as { type: "text"; text: string } | undefined; + if (textPart) { + textPart.text = `${hiddenPrompt}\n\n${textPart.text}`.trim(); + } else { + msg.parts.unshift({ type: "text", text: hiddenPrompt }); + } + break; + } + return cloned; +} + /** * 自定义消息转换函数 * 注意:gemini-image 和 huanke-gemini 都需要 URL 格式(不是 Base64) @@ -316,11 +336,13 @@ export async function POST(request: Request) { message, selectedChatModel, selectedVisibilityType, + hiddenPrompt, }: { id: string; message: ChatMessage; selectedChatModel: ChatModel["id"]; selectedVisibilityType: VisibilityType; + hiddenPrompt?: string; } = requestBody; let finalModelId = selectedChatModel; @@ -599,6 +621,11 @@ export async function POST(request: Request) { } const uiMessages = [...convertToUIMessages(messagesFromDb), message]; + const hiddenPromptText = typeof hiddenPrompt === "string" ? hiddenPrompt.trim() : ""; + const hiddenPromptPrefix = hiddenPromptText ? `${hiddenPromptText}\n\n` : ""; + const uiMessagesForModel = hiddenPromptText + ? applyHiddenPromptToMessages(uiMessages, hiddenPromptText) + : uiMessages; const { longitude, latitude, city, country } = geolocation(request); @@ -797,8 +824,8 @@ export async function POST(request: Request) { console.log(`[Chat API] ${isRetry ? "🔄 Retry with fallback" : "🚀 Trying"} model: ${modelIdToUse}`); // 只传递最后一条用户消息,避免上下文历史干扰生图效果 - const lastUserMessage = uiMessages.filter((m) => m.role === "user").pop(); - const messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessages; + const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop(); + const messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessagesForModel; console.log("[Chat API] Gemini: Using only last user message (no history context)"); // 转换消息:gemini-image 需要 base64 格式,huanke-gemini 需要 URL 格式 @@ -926,7 +953,7 @@ export async function POST(request: Request) { console.log("[Chat API] Executing Volcano Vision failover..."); // 转换消息(火山引擎会自动处理 Base64) - const volcanoMessages = await convertMessagesWithImages(uiMessages, "volcano"); + const volcanoMessages = await convertMessagesWithImages(uiMessagesForModel, "volcano"); // 调用火山引擎生成(故障转移时只生成 1 张,保持与 Gemini 一致) const volcanoResult = await (volcanoLanguageModel as any).doGenerate({ @@ -1091,8 +1118,8 @@ export async function POST(request: Request) { } // 只传递最后一条用户消息 - const lastUserMessage = uiMessages.filter((m) => m.role === "user").pop(); - let messagesForAlibaba = lastUserMessage ? [lastUserMessage] : uiMessages; + const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop(); + let messagesForAlibaba = lastUserMessage ? [lastUserMessage] : uiMessagesForModel; console.log("[Chat API] Alibaba: Using only last user message"); // 🆕 如果有智能模式优化后的提示词,替换消息中的文本(仅用于API调用) @@ -1102,7 +1129,7 @@ export async function POST(request: Request) { ...msg, parts: msg.parts.map((part) => { if (part.type === "text") { - return { ...part, text: smartAutoOptimizedPrompt! }; + return { ...part, text: `${hiddenPromptPrefix}${smartAutoOptimizedPrompt!}`.trim() }; } return part; }), @@ -1248,13 +1275,13 @@ export async function POST(request: Request) { // 🆕 检测是否有标注图片,如果有则在消息中注入系统提示 const hasAnnotation = hasAnnotatedImage(uiMessages); - let messagesForVolcano = uiMessages; + let messagesForVolcano = uiMessagesForModel; if (hasAnnotation) { console.log("[Chat API] 🎨 Volcano Vision: 检测到标注图片,将注入系统提示"); // 创建消息副本,修改最后一条用户消息的文本 - messagesForVolcano = uiMessages.map((msg, idx) => { - if (msg.role === "user" && idx === uiMessages.length - 1) { + messagesForVolcano = messagesForVolcano.map((msg, idx) => { + if (msg.role === "user" && idx === messagesForVolcano.length - 1) { // 修改最后一条用户消息 const modifiedParts = msg.parts.map((part) => { if (part.type === "text" && part.text) { @@ -1429,7 +1456,7 @@ export async function POST(request: Request) { execute: async ({ writer }) => { try { // 千问视频直接使用原始 URL,不转换为 base64 - const modelMessages = convertToModelMessages(uiMessages); + const modelMessages = convertToModelMessages(uiMessagesForModel); console.log("[Chat API] Qianwen video messages prepared, count:", modelMessages.length); // 调用 doGenerate 获取视频 @@ -1543,7 +1570,7 @@ export async function POST(request: Request) { console.log("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志 // 转换消息,对于 Gemini 和千问视频将图片 URL 转换为 base64 - const convertedMessages = await convertMessagesWithImages(uiMessages, (languageModel as any).provider || ""); + const convertedMessages = await convertMessagesWithImages(uiMessagesForModel, (languageModel as any).provider || ""); console.log("[Chat API] Messages converted, count:", convertedMessages.length); // 调试日志 // 🆕 检测是否是视频模型 @@ -1586,13 +1613,12 @@ export async function POST(request: Request) { } } } - // 备用方案1:content 字符串 - else if (typeof lastUserMessage.content === "string") { - prompt = lastUserMessage.content; - } - // 备用方案2:content 数组 - else if (Array.isArray(lastUserMessage.content)) { - for (const part of lastUserMessage.content) { + // 备用方案1:content 字符串/数组 + const legacyContent = (lastUserMessage as any).content; + if (typeof legacyContent === "string") { + prompt = legacyContent; + } else if (Array.isArray(legacyContent)) { + for (const part of legacyContent) { if (typeof part === "string") { prompt += part; } else if (part.type === "text") { @@ -1601,9 +1627,8 @@ export async function POST(request: Request) { imageUrls.push((part as any).image); } } - } - // 备用方案3:experimental_attachments - else if ((lastUserMessage as any).experimental_attachments) { + } else if ((lastUserMessage as any).experimental_attachments) { + // 备用方案2:experimental_attachments for (const attachment of (lastUserMessage as any).experimental_attachments) { if (attachment.url && attachment.contentType?.startsWith("image/")) { imageUrls.push(attachment.url); diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index 5969042..28e3cff 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -49,6 +49,7 @@ export const postRequestBodySchema = z.object({ role: z.enum(["user"]), parts: z.array(partSchema), }), + hiddenPrompt: z.string().max(4000).optional(), // 支持预定义模型名称或数据库中的模型 UUID selectedChatModel: z.string().min(1).max(128), selectedVisibilityType: z.enum(["public", "private"]), diff --git a/app/api/canvas/auto-title/route.ts b/app/api/canvas/auto-title/route.ts index 75e75a5..7846c6b 100644 --- a/app/api/canvas/auto-title/route.ts +++ b/app/api/canvas/auto-title/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { generateText } from "ai"; -import { getEnabledAiModelsWithProvider, getAiProviderById } from "@/lib/db/queries"; -import { createOpenAI } from "@ai-sdk/openai"; +import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; +import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; export async function POST(request: NextRequest) { console.log("[Auto-title] API called"); @@ -21,22 +21,25 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Image URL is required" }, { status: 400 }); } - // 从数据库获取支持视觉的模型(查找包含 qwen-vl 或 vision 的模型) + // 从数据库获取支持视觉的模型(优先使用 supportsVision) const modelsData = await getEnabledAiModelsWithProvider(); console.log("[Auto-title] Available models count:", modelsData.length); console.log("[Auto-title] All model IDs:", modelsData.map(m => m.model.modelId)); - // 查找千问VL模型(支持多种命名格式) - const visionModelData = modelsData.find(m => { - const id = m.model.modelId?.toLowerCase() || ''; - const name = m.model.displayName?.toLowerCase() || ''; - return id.includes('qwen-vl') || - id.includes('qwen3-vl') || - id.includes('qwen2.5-vl') || - id.includes('vision') || - name.includes('vl') || - name.includes('视觉'); - }); + let visionModelData = modelsData.find(m => m.model.supportsVision); + if (!visionModelData) { + // 兼容旧配置:根据模型名称兜底匹配 + visionModelData = modelsData.find(m => { + const id = m.model.modelId?.toLowerCase() || ''; + const name = m.model.displayName?.toLowerCase() || ''; + return id.includes('qwen-vl') || + id.includes('qwen3-vl') || + id.includes('qwen2.5-vl') || + id.includes('vision') || + name.includes('vl') || + name.includes('视觉'); + }); + } console.log("[Auto-title] Found vision model:", visionModelData?.model.modelId, visionModelData?.model.displayName); @@ -45,31 +48,18 @@ export async function POST(request: NextRequest) { return NextResponse.json({ title: "未命名画布" }); } - // 直接使用返回的 provider 信息 - const provider = visionModelData.provider; const visionModel = visionModelData.model; - if (!provider || !provider.apiKey || !provider.baseUrl) { - console.log("[Auto-title] Provider not configured"); + const model = await getLanguageModelById(visionModel.id); + if (!model) { + console.log("[Auto-title] Failed to load vision model"); return NextResponse.json({ title: "未命名画布" }); } - console.log("[Auto-title] Using provider:", provider.name, "baseUrl:", provider.baseUrl); - // 构造 Prompt const prompt = "请用2-4个字概括这张图片的主体内容,作为文件标题。直接返回标题,不要包含任何标点符号或其他文字。"; - // 使用数据库配置的 provider - const openai = createOpenAI({ - apiKey: provider.apiKey, - baseURL: provider.baseUrl, - }); - - // 直接使用数据库中的模型 ID(阿里云支持 qwen3-vl-plus 等新模型 ID) - const actualModelId = visionModel.modelId; - console.log("[Auto-title] Using model ID:", actualModelId); - const { text } = await generateText({ - model: openai(actualModelId), + model, messages: [ { role: 'user', @@ -79,10 +69,16 @@ export async function POST(request: NextRequest) { ], }, ], + temperature: 0.2, + maxTokens: 50, }); - console.log("[Auto-title] Generated title:", text.trim()); - return NextResponse.json({ title: text.trim() }); + let title = (text || "").trim(); + title = title.replace(/[\s\p{P}\p{S}]+/gu, ""); + if (title.length > 4) title = title.slice(0, 4); + if (title.length < 2) title = "新画布"; + console.log("[Auto-title] Generated title:", title); + return NextResponse.json({ title }); } catch (error) { console.error("[Auto-title] Error:", error); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 29f5b25..73c2ab9 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -141,6 +141,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); + const hiddenPromptRef = useRef(null); // 🆕 标签悬停预览状态 const [tagPreview, setTagPreview] = useState(null); @@ -548,6 +549,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF imageParams: imageParamsRef.current, lastMessage: request.messages.at(-1), }); + const hiddenPrompt = hiddenPromptRef.current || undefined; + hiddenPromptRef.current = null; return { body: { id: rawId, @@ -556,6 +559,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF selectedVisibilityType: "private", videoParams: undefined, // 必须传递,即使是 undefined imageParams: imageParamsRef.current || undefined, // 传递图片参数 + hiddenPrompt, ...request.body, }, }; @@ -787,7 +791,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const hasAnnotatedImages = annotatedNodes.length > 0; // 添加文件部分(作为 file 类型的 part) - // 🆕 收集物品信息用于构建增强 prompt + // 🆕 收集物品信息用于构建隐藏提示 const pickedObjectInfos: string[] = []; effectiveAttachments.forEach((a) => { @@ -810,21 +814,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } }); - // 添加文本部分 + // 添加文本部分(用户可见) let textContent = input.trim() || (effectiveAttachments.length > 0 ? "请处理这张图片" : ""); - - // 🆕 如果有拾取的物品,在文本前添加物品信息 - if (pickedObjectInfos.length > 0 && textContent) { + + // 🆕 隐藏提示词(不在 UI 中展示) + const hiddenSegments: string[] = []; + if (pickedObjectInfos.length > 0) { const objectsPrompt = pickedObjectInfos.join('\n'); - textContent = `【用户指定的物品】\n${objectsPrompt}\n\n【用户指令】${textContent}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`; - console.log('[CanvasChat] 检测到拾取物品,已添加物品信息:', pickedObjectInfos.length, '个'); + hiddenSegments.push( + `【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。` + ); + console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个'); } - - // 🆕 如果有标注的图片,在文本中添加提示 - if (hasAnnotatedImages && textContent) { - textContent = `【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n${textContent}`; - console.log('[CanvasChat] 检测到标注图片,已添加标注提示'); + if (hasAnnotatedImages) { + hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】"); + console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示'); } + const hiddenPrompt = hiddenSegments.join('\n\n').trim(); + hiddenPromptRef.current = hiddenPrompt || null; if (textContent) { parts.push({ type: "text" as const, text: textContent }); @@ -1070,7 +1077,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF onClick={() => { // 创建新画布并导航 const newId = crypto.randomUUID(); - router.push(`/canvas/${newId}`); + const targetUrl = `/canvas/${newId}`; + setTimeout(() => { + window.location.assign(targetUrl); + }, 100); }} title="新建画布" > diff --git a/components/canvas/CanvasFileListPanel.tsx b/components/canvas/CanvasFileListPanel.tsx index 23fd53d..4c5d069 100644 --- a/components/canvas/CanvasFileListPanel.tsx +++ b/components/canvas/CanvasFileListPanel.tsx @@ -52,9 +52,12 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP onClose(); return; } - // 使用 router.push 并关闭面板,让页面组件的 useEffect 处理加载 - router.push(`/canvas/${id}`); - onClose(); // 关闭文件列表面板,切换到聊天面板 + const targetUrl = `/canvas/${id}`; + onClose(); // 关闭文件列表面板 + // 强制整页跳转,避免仅更新 URL 不刷新 + setTimeout(() => { + window.location.assign(targetUrl); + }, 100); }; // 删除画布 diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 245413d..82e5629 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -10,6 +10,8 @@ import { TextNode } from "./nodes/TextNode"; import { VideoNode } from "./nodes/VideoNode"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { imageGenerationService } from "@/services/canvas"; import { Minus, Plus } from "lucide-react"; // 性能优化:判断节点是否在视口内 @@ -45,6 +47,7 @@ export function CanvasWorkspace() { const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); const viewport = useCanvasStore(canvasSelectors.viewport); const tool = useCanvasStore(canvasSelectors.tool); + const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId); // 性能优化:监听容器大小变化 React.useEffect(() => { @@ -77,9 +80,13 @@ export function CanvasWorkspace() { const setViewport = useCanvasStore((state) => state.setViewport); const selectNode = useCanvasStore((state) => state.selectNode); const clearSelection = useCanvasStore((state) => state.clearSelection); + const addNode = useCanvasStore((state) => state.addNode); const moveNode = useCanvasStore((state) => state.moveNode); const resizeNode = useCanvasStore((state) => state.resizeNode); const pushHistory = useCanvasStore((state) => state.pushHistory); + const updateNode = useCanvasStore((state) => state.updateNode); + const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId); + const centerTimeoutRef = useRef(null); // 处理画布点击(取消选择) const handleCanvasClick = useCallback((e: React.MouseEvent) => { @@ -112,6 +119,39 @@ export function CanvasWorkspace() { setIsPanning(false); }, []); + // 新增图片后自动居中展示 + const centerNodeInView = useCallback((nodeId: string) => { + const node = nodes.find(n => n.id === nodeId); + if (!node) return; + const width = containerSize.width || 0; + const height = containerSize.height || 0; + if (width === 0 || height === 0) return; + const zoom = viewport.zoom; + const centerX = node.position.x + node.size.width / 2; + const centerY = node.position.y + node.size.height / 2; + const newX = width / 2 - centerX * zoom; + const newY = height / 2 - centerY * zoom; + setViewport({ x: newX, y: newY }); + }, [containerSize.width, containerSize.height, nodes, setViewport, viewport.zoom]); + + React.useEffect(() => { + if (!lastAddedNodeId) return; + if (containerSize.width === 0 || containerSize.height === 0) return; + if (centerTimeoutRef.current) { + window.clearTimeout(centerTimeoutRef.current); + } + centerTimeoutRef.current = window.setTimeout(() => { + centerNodeInView(lastAddedNodeId); + clearLastAddedNodeId(); + }, 80); + return () => { + if (centerTimeoutRef.current) { + window.clearTimeout(centerTimeoutRef.current); + centerTimeoutRef.current = null; + } + }; + }, [lastAddedNodeId, centerNodeInView, clearLastAddedNodeId, containerSize.width, containerSize.height]); + // 处理滚轮缩放 const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); @@ -142,6 +182,108 @@ export function CanvasWorkspace() { } }, [viewport, setViewport]); + const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number }> => { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + const maxSize = 500; + let width = img.naturalWidth; + let height = img.naturalHeight; + if (width > maxSize || height > maxSize) { + const scale = Math.min(maxSize / width, maxSize / height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + const minSize = 150; + if (width < minSize && height < minSize) { + const scale = minSize / Math.min(width, height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + URL.revokeObjectURL(url); + resolve({ width, height }); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve({ width: 300, height: 300 }); + }; + img.src = url; + }); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer?.types?.includes?.("Files")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/")); + if (files.length === 0) return; + + const rect = containerRef.current?.getBoundingClientRect(); + const baseX = rect ? (e.clientX - rect.left - viewport.x) / viewport.zoom : 100; + const baseY = rect ? (e.clientY - rect.top - viewport.y) / viewport.zoom : 100; + const spacing = 20; + + for (let index = 0; index < files.length; index++) { + const file = files[index]; + if (file.size > 20 * 1024 * 1024) { + toast.error("图片大小不能超过 20MB"); + continue; + } + + const dimensions = await getImageDimensionsFromFile(file); + const placeholderId = addNode({ + type: "image", + position: { x: baseX + index * spacing, y: baseY + index * spacing }, + size: { width: dimensions.width, height: dimensions.height }, + data: { + src: "", + status: "generating", + prompt: file.name || "拖拽上传", + }, + connections: [], + }); + + try { + const result = await imageGenerationService.uploadImage(file); + if (result.success && result.data?.url) { + updateNode(placeholderId, { + data: { + src: result.data.url, + status: "completed", + prompt: file.name || "拖拽上传", + }, + }); + toast.success("图片已添加到画布"); + } else { + updateNode(placeholderId, { + data: { + src: "", + status: "failed", + prompt: file.name || "拖拽上传", + }, + }); + toast.error(result.error || "图片上传失败"); + } + } catch (error) { + updateNode(placeholderId, { + data: { + src: "", + status: "failed", + prompt: file.name || "拖拽上传", + }, + }); + toast.error("图片上传失败"); + } + } + }, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); + // 渲染节点 const renderNode = (node: CanvasNode) => { const isSelected = selectedNodeIds.includes(node.id); @@ -231,6 +373,8 @@ export function CanvasWorkspace() { onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel} + onDragOver={handleDragOver} + onDrop={handleDrop} > {/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */}
{ if (!data.src) return; + if (isProcessing) { + toast.info("正在处理中,请稍候..."); + return; + } setResizeDialogOpen(false); + setIsProcessing(true); // 创建处理中节点 const newNodeId = addNode({ @@ -566,6 +578,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { }, }); toast.error('尺寸调整失败'); + } finally { + setIsProcessing(false); } }; @@ -861,7 +875,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { {/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} - {isSelected && data.src && !isProcessing && typeof document !== 'undefined' && createPortal( + {isSelected && data.src && typeof document !== 'undefined' && createPortal(
{ addPendingChatAttachment({ url: data.src!, @@ -901,7 +916,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {