From c7ffcda87bfbffc2c8f9c80a26b08c91ef9284ae Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 24 Jan 2026 19:48:13 +0800 Subject: [PATCH] feat: add infinite canvas feature with AI image generation - Add canvas project and node database tables - Create canvas workspace with drag, resize, zoom and pan - Add ImageNode with background removal, enhance, extend, angle edit, scene fusion - Add VideoNode with playback controls - Add TextNode for text editing - Integrate AI image generation API (Gemini/Alibaba) - Implement viewport culling for performance optimization - Add canvas entry in top navigation bar --- .../settings/admin-settings-client.tsx | 2 +- app/(chat)/canvas/page.tsx | 289 +++ app/api/canvas/[id]/route.ts | 116 ++ app/api/canvas/generate/image/route.ts | 149 ++ app/api/canvas/route.ts | 58 + app/canvas/[id]/layout.tsx | 18 + app/canvas/[id]/page.tsx | 750 +++++++ components/canvas/CanvasWorkspace.tsx | 270 +++ components/canvas/index.ts | 4 + components/canvas/nodes/ImageNode.tsx | 279 +++ components/canvas/nodes/TextNode.tsx | 99 + components/canvas/nodes/VideoNode.tsx | 247 +++ components/top-navbar.tsx | 15 +- docs/无限画布集成开发方案.md | 1354 +++++++++++++ lib/db/migrations/meta/0013_snapshot.json | 1749 +++++++++++++++++ lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 43 + package.json | 7 +- pnpm-lock.yaml | 129 +- services/canvas/index.ts | 260 +++ store/canvas/index.ts | 397 ++++ types/canvas/index.ts | 162 ++ 22 files changed, 6385 insertions(+), 19 deletions(-) create mode 100644 app/(chat)/canvas/page.tsx create mode 100644 app/api/canvas/[id]/route.ts create mode 100644 app/api/canvas/generate/image/route.ts create mode 100644 app/api/canvas/route.ts create mode 100644 app/canvas/[id]/layout.tsx create mode 100644 app/canvas/[id]/page.tsx create mode 100644 components/canvas/CanvasWorkspace.tsx create mode 100644 components/canvas/index.ts create mode 100644 components/canvas/nodes/ImageNode.tsx create mode 100644 components/canvas/nodes/TextNode.tsx create mode 100644 components/canvas/nodes/VideoNode.tsx create mode 100644 docs/无限画布集成开发方案.md create mode 100644 lib/db/migrations/meta/0013_snapshot.json create mode 100644 services/canvas/index.ts create mode 100644 store/canvas/index.ts create mode 100644 types/canvas/index.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index de6122b68..89e63f626 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -3215,7 +3215,7 @@ export function AdminSettingsClient() {
存储配置 配置 S3 兼容对象存储 -
+ + + + {/* 画布列表 */} +
+ {isLoading ? ( +
+ +
+ ) : projects.length === 0 ? ( +
+ +

还没有画布

+

+ 创建一个新画布,开始你的创作之旅 +

+ +
+ ) : ( +
+ {projects.map((project) => ( + router.push(`/canvas/${project.id}`)} + > + +
+ + {project.title} + + + e.stopPropagation()}> + + + + { + e.stopPropagation(); + setProjectToRename(project); + setNewTitle(project.title); + setRenameDialogOpen(true); + }} + > + + 重命名 + + { + e.stopPropagation(); + setProjectToDelete(project); + setDeleteDialogOpen(true); + }} + > + + 删除 + + + +
+
+ +
+ {project.thumbnail ? ( + {project.title} + ) : ( + + )} +
+
+ +
+ + {formatDate(project.updatedAt)} +
+
+
+ ))} +
+ )} +
+ + {/* 删除确认对话框 */} + + + + 确认删除 + + 确定要删除画布 "{projectToDelete?.title}" 吗?此操作无法撤销。 + + + + + + + + + + {/* 重命名对话框 */} + + + + 重命名画布 + + setNewTitle(e.target.value)} + placeholder="输入新名称" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleRenameProject(); + } + }} + /> + + + + + + + + ); +} diff --git a/app/api/canvas/[id]/route.ts b/app/api/canvas/[id]/route.ts new file mode 100644 index 000000000..791d5787d --- /dev/null +++ b/app/api/canvas/[id]/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/app/(auth)/auth'; +import { db } from '@/lib/db'; +import { canvasProject } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; + +// 获取单个画布 +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); + } + + const { id } = await params; + + const [project] = await db + .select() + .from(canvasProject) + .where( + and( + eq(canvasProject.id, id), + eq(canvasProject.userId, session.user.id) + ) + ); + + if (!project) { + return NextResponse.json({ success: false, error: '画布不存在' }, { status: 404 }); + } + + return NextResponse.json({ success: true, data: project }); + } catch (error) { + console.error('获取画布失败:', error); + return NextResponse.json({ success: false, error: '获取画布失败' }, { status: 500 }); + } +} + +// 更新画布 +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const { title, description, thumbnail, canvasData } = body; + + const updateData: any = { updatedAt: new Date() }; + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (thumbnail !== undefined) updateData.thumbnail = thumbnail; + if (canvasData !== undefined) updateData.canvasData = canvasData; + + const [updatedProject] = await db + .update(canvasProject) + .set(updateData) + .where( + and( + eq(canvasProject.id, id), + eq(canvasProject.userId, session.user.id) + ) + ) + .returning(); + + if (!updatedProject) { + return NextResponse.json({ success: false, error: '画布不存在' }, { status: 404 }); + } + + return NextResponse.json({ success: true, data: updatedProject }); + } catch (error) { + console.error('更新画布失败:', error); + return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 }); + } +} + +// 删除画布 +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); + } + + const { id } = await params; + + const [deletedProject] = await db + .delete(canvasProject) + .where( + and( + eq(canvasProject.id, id), + eq(canvasProject.userId, session.user.id) + ) + ) + .returning(); + + if (!deletedProject) { + return NextResponse.json({ success: false, error: '画布不存在' }, { status: 404 }); + } + + return NextResponse.json({ success: true, data: deletedProject }); + } catch (error) { + console.error('删除画布失败:', error); + return NextResponse.json({ success: false, error: '删除画布失败' }, { status: 500 }); + } +} diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts new file mode 100644 index 000000000..7e881774b --- /dev/null +++ b/app/api/canvas/generate/image/route.ts @@ -0,0 +1,149 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getEnabledImageModels, getProviderById } from "@/lib/db/queries"; +import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; +import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image"; + +export const maxDuration = 120; + +interface GenerateImageRequest { + prompt: string; + negativePrompt?: string; + model?: string; // 模型 ID + aspectRatio?: string; // 宽高比 "1:1", "16:9" 等 + imageCount?: number; // 生成数量 + referenceImage?: string; // 参考图片(用于图生图) +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const body: GenerateImageRequest = await request.json(); + const { prompt, negativePrompt, model, aspectRatio = "1:1", imageCount = 1, referenceImage } = body; + + if (!prompt) { + return NextResponse.json( + { success: false, error: "请输入提示词" }, + { status: 400 } + ); + } + + // 获取可用的图片生成模型 + const imageModels = await getEnabledImageModels(); + + if (!imageModels || imageModels.length === 0) { + return NextResponse.json( + { success: false, error: "暂无可用的图片生成模型" }, + { status: 400 } + ); + } + + // 选择模型(如果指定了 model,使用指定的;否则使用默认的) + let selectedModel = model + ? imageModels.find(m => m.model.modelId === model) + : imageModels.find(m => m.model.isDefault) || imageModels[0]; + + if (!selectedModel) { + selectedModel = imageModels[0]; + } + + const provider = await getProviderById(selectedModel.model.providerId); + if (!provider) { + return NextResponse.json( + { success: false, error: "模型提供商配置错误" }, + { status: 500 } + ); + } + + // 计算尺寸 + const sizeMap: Record = { + "1:1": { width: 1024, height: 1024 }, + "3:2": { width: 1536, height: 1024 }, + "2:3": { width: 1024, height: 1536 }, + "4:3": { width: 1365, height: 1024 }, + "3:4": { width: 1024, height: 1365 }, + "16:9": { width: 1820, height: 1024 }, + "9:16": { width: 1024, height: 1820 }, + }; + const size = sizeMap[aspectRatio] || sizeMap["1:1"]; + + // 构建消息 + const messages = [ + { + role: "user" as const, + content: prompt, + } + ]; + + // 图片参数 + const imageParams = { + aspectRatio, + imageSize: `${size.width}x${size.height}`, + imageCount, + negativePrompt, + }; + + let result; + + // 根据提供商类型调用不同的生成器 + if (provider.type === "gemini") { + const geminiProvider = createGeminiImage({ + apiKey: provider.apiKey, + baseURL: provider.baseUrl, + }); + + result = await geminiProvider.doGenerate({ + messages, + modelId: selectedModel.model.modelId, + imageParams, + }); + } else if (provider.type === "alibaba-image") { + const alibabaProvider = createAlibabaImage({ + apiKey: provider.apiKey, + baseURL: provider.baseUrl, + }); + + result = await alibabaProvider.doGenerate({ + messages, + modelId: selectedModel.model.modelId, + imageParams, + referenceImage, + }); + } else { + return NextResponse.json( + { success: false, error: "不支持的图片生成模型类型" }, + { status: 400 } + ); + } + + if (result.images && result.images.length > 0) { + return NextResponse.json({ + success: true, + data: { + images: result.images, + model: selectedModel.model.displayName, + prompt, + } + }); + } else { + return NextResponse.json( + { success: false, error: "图片生成失败" }, + { status: 500 } + ); + } + + } catch (error) { + console.error("[Canvas Generate Image] Error:", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "生成失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/canvas/route.ts b/app/api/canvas/route.ts new file mode 100644 index 000000000..0cf370c91 --- /dev/null +++ b/app/api/canvas/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/app/(auth)/auth'; +import { db } from '@/lib/db'; +import { canvasProject } from '@/lib/db/schema'; +import { eq, desc } from 'drizzle-orm'; + +// 获取画布列表 +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); + } + + const projects = await db + .select() + .from(canvasProject) + .where(eq(canvasProject.userId, session.user.id)) + .orderBy(desc(canvasProject.updatedAt)); + + return NextResponse.json({ success: true, data: projects }); + } catch (error) { + console.error('获取画布列表失败:', error); + return NextResponse.json({ success: false, error: '获取画布列表失败' }, { status: 500 }); + } +} + +// 创建画布 +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); + } + + const body = await request.json(); + const { title, description, canvasData } = body; + + const [newProject] = await db + .insert(canvasProject) + .values({ + userId: session.user.id, + title: title || '未命名画布', + description, + canvasData: canvasData || { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + }) + .returning(); + + return NextResponse.json({ success: true, data: newProject }); + } catch (error) { + console.error('创建画布失败:', error); + return NextResponse.json({ success: false, error: '创建画布失败' }, { status: 500 }); + } +} diff --git a/app/canvas/[id]/layout.tsx b/app/canvas/[id]/layout.tsx new file mode 100644 index 000000000..dcd0cb7df --- /dev/null +++ b/app/canvas/[id]/layout.tsx @@ -0,0 +1,18 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "无限画布", + description: "AI 驱动的无限画布创作工具", +}; + +export default function CanvasEditorLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx new file mode 100644 index 000000000..14f35f530 --- /dev/null +++ b/app/canvas/[id]/page.tsx @@ -0,0 +1,750 @@ +"use client"; + +import { useEffect, useState, useCallback, use } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + ArrowLeft, + Save, + Undo2, + Redo2, + ZoomIn, + ZoomOut, + MousePointer, + Hand, + Plus, + ImagePlus, + Video, + Type, + Loader2, + Settings, + Trash2, + Scissors, + Sparkles, + Expand, + Maximize2, + Move3D, + Layers, + MoreHorizontal, + PanelLeftClose, + PanelLeft, + Download, + Upload, + Wand2, + Grid3X3, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { toast } from "sonner"; +import { useCanvasStore, canvasSelectors } from "@/store/canvas"; +import { canvasService, imageGenerationService } from "@/services/canvas"; +import { CanvasWorkspace } from "@/components/canvas"; +import { cn } from "@/lib/utils"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +// 图片尺寸预设 +const SIZE_PRESETS = [ + { label: "1:1", value: "1:1", width: 1024, height: 1024 }, + { label: "3:2", value: "3:2", width: 1536, height: 1024 }, + { label: "2:3", value: "2:3", width: 1024, height: 1536 }, + { label: "4:3", value: "4:3", width: 1365, height: 1024 }, + { label: "3:4", value: "3:4", width: 1024, height: 1365 }, + { label: "16:9", value: "16:9", width: 1820, height: 1024 }, + { label: "9:16", value: "9:16", width: 1024, height: 1820 }, +]; + +export default function CanvasEditorPage({ params }: PageProps) { + const { id } = use(params); + const router = useRouter(); + + // Store state + const projectTitle = useCanvasStore((state) => state.projectTitle); + const viewport = useCanvasStore(canvasSelectors.viewport); + const tool = useCanvasStore(canvasSelectors.tool); + const canUndo = useCanvasStore(canvasSelectors.canUndo); + const canRedo = useCanvasStore(canvasSelectors.canRedo); + const isGenerating = useCanvasStore(canvasSelectors.isGenerating); + const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); + const nodes = useCanvasStore(canvasSelectors.nodes); + + // Store actions + const setProject = useCanvasStore((state) => state.setProject); + const setProjectTitle = useCanvasStore((state) => state.setProjectTitle); + const setTool = useCanvasStore((state) => state.setTool); + const zoomIn = useCanvasStore((state) => state.zoomIn); + const zoomOut = useCanvasStore((state) => state.zoomOut); + const undo = useCanvasStore((state) => state.undo); + const redo = useCanvasStore((state) => state.redo); + const loadCanvas = useCanvasStore((state) => state.loadCanvas); + const getCanvasState = useCanvasStore((state) => state.getCanvasState); + const addNode = useCanvasStore((state) => state.addNode); + const updateNode = useCanvasStore((state) => state.updateNode); + const setGenerating = useCanvasStore((state) => state.setGenerating); + + // Local state + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editTitle, setEditTitle] = useState(""); + const [isPanelOpen, setIsPanelOpen] = useState(true); + + // 生成配置 + const [prompt, setPrompt] = useState(""); + const [negativePrompt, setNegativePrompt] = useState(""); + const [aspectRatio, setAspectRatio] = useState("1:1"); + const [imageCount, setImageCount] = useState(1); + + // 获取选中的图片节点 + const selectedImageNode = selectedNodeIds.length === 1 + ? nodes.find(n => n.id === selectedNodeIds[0] && n.type === 'image') + : null; + + // 加载画布数据 + useEffect(() => { + const loadProject = async () => { + setIsLoading(true); + const result = await canvasService.getProject(id); + + if (result.success && result.data) { + setProject(id, result.data.title); + if (result.data.canvasData) { + loadCanvas(result.data.canvasData); + } + } else { + toast.error(result.error || "加载画布失败"); + router.push("/canvas"); + } + setIsLoading(false); + }; + + loadProject(); + }, [id]); + + // 保存画布 + const handleSave = useCallback(async () => { + setIsSaving(true); + const canvasData = getCanvasState(); + const result = await canvasService.saveCanvasState(id, canvasData); + + if (result.success) { + toast.success("保存成功"); + } else { + toast.error(result.error || "保存失败"); + } + setIsSaving(false); + }, [id, getCanvasState]); + + // 更新标题 + const handleUpdateTitle = async () => { + if (!editTitle.trim() || editTitle === projectTitle) { + setIsEditingTitle(false); + return; + } + + const result = await canvasService.updateProject(id, { title: editTitle.trim() }); + if (result.success) { + setProjectTitle(editTitle.trim()); + } else { + toast.error(result.error || "更新标题失败"); + } + setIsEditingTitle(false); + }; + + // 添加文本节点 + const handleAddTextNode = () => { + addNode({ + type: 'text', + position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, + size: { width: 200, height: 100 }, + data: { content: '双击编辑文本' }, + connections: [], + }); + }; + + // 添加图片节点 + const handleAddImageNode = () => { + addNode({ + type: 'image', + position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, + size: { width: 300, height: 300 }, + data: { + src: '', + status: 'pending', + prompt: '', + }, + connections: [], + }); + }; + + // 添加视频节点 + const handleAddVideoNode = () => { + addNode({ + type: 'video', + position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, + size: { width: 400, height: 300 }, + data: { + src: '', + status: 'pending', + prompt: '', + }, + connections: [], + }); + }; + + // AI 生成图片 + const handleGenerate = async () => { + if (!prompt.trim()) { + toast.error("请输入提示词"); + return; + } + + setGenerating(true); + + // 获取尺寸预设 + const sizePreset = SIZE_PRESETS.find(s => s.value === aspectRatio) || SIZE_PRESETS[0]; + + // 先创建占位节点(显示生成中状态) + const placeholderNodeIds: string[] = []; + for (let i = 0; i < imageCount; i++) { + const nodeId = addNode({ + type: 'image', + position: { + x: 100 + (i % 3) * 320, + y: 100 + Math.floor(i / 3) * 320 + }, + size: { width: 300, height: 300 }, + data: { + src: '', + status: 'generating', + prompt: prompt, + }, + connections: [], + }); + placeholderNodeIds.push(nodeId); + } + + try { + // 调用 AI 图片生成 API + const result = await imageGenerationService.generateImage({ + prompt, + negativePrompt, + aspectRatio, + imageCount, + }); + + if (result.success && result.data?.images) { + // 更新节点为生成的图片 + result.data.images.forEach((image, index) => { + if (placeholderNodeIds[index]) { + updateNode(placeholderNodeIds[index], { + data: { + src: image.originalUrl || image.thumbnailUrl, + status: 'completed', + prompt: prompt, + }, + }); + } + }); + + toast.success(`成功生成 ${result.data.images.length} 张图片`); + setPrompt(""); // 清空提示词 + } else { + // 生成失败,更新节点状态 + placeholderNodeIds.forEach(nodeId => { + updateNode(nodeId, { + data: { + src: '', + status: 'failed', + prompt: prompt, + }, + }); + }); + toast.error(result.error || "图片生成失败"); + } + } catch (error) { + console.error("AI 图片生成失败:", error); + // 生成失败,更新节点状态 + placeholderNodeIds.forEach(nodeId => { + updateNode(nodeId, { + data: { + src: '', + status: 'failed', + prompt: prompt, + }, + }); + }); + toast.error("图片生成失败,请重试"); + } finally { + setGenerating(false); + } + }; + + // 键盘快捷键 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // 如果正在输入则不触发快捷键 + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + // Ctrl/Cmd + S 保存 + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + handleSave(); + } + // Ctrl/Cmd + Z 撤销 + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + undo(); + } + // Ctrl/Cmd + Shift + Z 重做 + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) { + e.preventDefault(); + redo(); + } + // V 选择工具 + if (e.key === 'v' && !e.ctrlKey && !e.metaKey) { + setTool('select'); + } + // H 平移工具 + if (e.key === 'h' && !e.ctrlKey && !e.metaKey) { + setTool('pan'); + } + // Delete 删除选中 + if (e.key === 'Delete' || e.key === 'Backspace') { + useCanvasStore.getState().deleteSelectedNodes(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleSave, undo, redo, setTool]); + + if (isLoading) { + return ( +
+
+ + 加载中... +
+
+ ); + } + + return ( + +
+ {/* 顶部工具栏 */} +
+ {/* 左侧:返回 + 标题 */} +
+ + + + + 返回列表 + + + {isEditingTitle ? ( + setEditTitle(e.target.value)} + onBlur={handleUpdateTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') handleUpdateTitle(); + if (e.key === 'Escape') setIsEditingTitle(false); + }} + className="h-8 w-48" + autoFocus + /> + ) : ( + + )} +
+ + {/* 中间:工具栏 */} +
+ {/* 选择/平移工具 */} +
+ + + + + 选择工具 (V) + + + + + + 平移工具 (H) + +
+ + + + {/* 撤销/重做 */} + + + + + 撤销 (Ctrl+Z) + + + + + + 重做 (Ctrl+Shift+Z) + + + + + {/* 缩放 */} + + + + + 缩小 + + + {Math.round(viewport.zoom * 100)}% + + + + + + 放大 + + + + + {/* 添加节点 */} + + + + + + + + 文本节点 + + + + 图片节点 + + + + + +
+ + {/* 右侧:面板切换 + 保存 */} +
+ {isGenerating && ( +
+ + 生成中... +
+ )} + + + + + {isPanelOpen ? "隐藏面板" : "显示面板"} + + + + + + 保存 (Ctrl+S) + +
+
+ + {/* 主体区域 */} +
+ {/* 左侧配置面板 - 参考 lobe-chat-pro */} +
+ {isPanelOpen && ( + +
+ {/* AI 图片生成 */} +
+
+ +

AI 图片生成

+
+ +
+
+ +