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
This commit is contained in:
Your Name 2026-01-24 19:48:13 +08:00
parent acaffcb65a
commit c7ffcda87b
22 changed files with 6385 additions and 19 deletions

View File

@ -3215,7 +3215,7 @@ export function AdminSettingsClient() {
<div>
<CardTitle>存储配置</CardTitle>
<CardDescription>配置 S3 兼容对象存储</CardDescription>
</div>
</div>
<Dialog open={showStorageDialog} onOpenChange={setShowStorageDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetStorageForm(); setShowStorageDialog(true); }}>

289
app/(chat)/canvas/page.tsx Normal file
View File

@ -0,0 +1,289 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, LayoutGrid, Trash2, Edit2, MoreVertical, Clock, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { canvasService } from "@/services/canvas";
interface CanvasProject {
id: string;
title: string;
description?: string;
thumbnail?: string;
createdAt: string;
updatedAt: string;
}
export default function CanvasListPage() {
const router = useRouter();
const [projects, setProjects] = useState<CanvasProject[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<CanvasProject | null>(null);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [projectToRename, setProjectToRename] = useState<CanvasProject | null>(null);
const [newTitle, setNewTitle] = useState("");
// 加载画布列表
const loadProjects = async () => {
setIsLoading(true);
const result = await canvasService.getProjects();
if (result.success && result.data) {
setProjects(result.data);
} else {
toast.error(result.error || "加载失败");
}
setIsLoading(false);
};
useEffect(() => {
loadProjects();
}, []);
// 创建新画布
const handleCreateProject = async () => {
setIsCreating(true);
const result = await canvasService.createProject({
title: "未命名画布",
});
if (result.success && result.data) {
toast.success("创建成功");
router.push(`/canvas/${result.data.id}`);
} else {
toast.error(result.error || "创建失败");
setIsCreating(false);
}
};
// 删除画布
const handleDeleteProject = async () => {
if (!projectToDelete) return;
const result = await canvasService.deleteProject(projectToDelete.id);
if (result.success) {
toast.success("删除成功");
setProjects(projects.filter(p => p.id !== projectToDelete.id));
} else {
toast.error(result.error || "删除失败");
}
setDeleteDialogOpen(false);
setProjectToDelete(null);
};
// 重命名画布
const handleRenameProject = async () => {
if (!projectToRename || !newTitle.trim()) return;
const result = await canvasService.updateProject(projectToRename.id, {
title: newTitle.trim(),
});
if (result.success) {
toast.success("重命名成功");
setProjects(projects.map(p =>
p.id === projectToRename.id ? { ...p, title: newTitle.trim() } : p
));
} else {
toast.error(result.error || "重命名失败");
}
setRenameDialogOpen(false);
setProjectToRename(null);
setNewTitle("");
};
// 格式化时间
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="flex flex-col h-full">
{/* 页面标题 */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<LayoutGrid className="h-6 w-6 text-purple-500" />
<h1 className="text-2xl font-bold">无限画布</h1>
</div>
<Button onClick={handleCreateProject} disabled={isCreating}>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
新建画布
</Button>
</div>
{/* 画布列表 */}
<div className="flex-1 overflow-auto p-6">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<LayoutGrid className="h-16 w-16 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">还没有画布</h3>
<p className="text-muted-foreground mb-4">
创建一个新画布,开始你的创作之旅
</p>
<Button onClick={handleCreateProject} disabled={isCreating}>
{isCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
新建画布
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projects.map((project) => (
<Card
key={project.id}
className="group cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => router.push(`/canvas/${project.id}`)}
>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-base font-medium line-clamp-1">
{project.title}
</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setProjectToRename(project);
setNewTitle(project.title);
setRenameDialogOpen(true);
}}
>
<Edit2 className="h-4 w-4 mr-2" />
重命名
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
setProjectToDelete(project);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="aspect-video bg-muted rounded-md flex items-center justify-center overflow-hidden">
{project.thumbnail ? (
<img
src={project.thumbnail}
alt={project.title}
className="w-full h-full object-cover"
/>
) : (
<LayoutGrid className="h-12 w-12 text-muted-foreground/30" />
)}
</div>
</CardContent>
<CardFooter className="p-4 pt-0">
<div className="flex items-center text-xs text-muted-foreground">
<Clock className="h-3 w-3 mr-1" />
{formatDate(project.updatedAt)}
</div>
</CardFooter>
</Card>
))}
</div>
)}
</div>
{/* 删除确认对话框 */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
<DialogDescription>
确定要删除画布 "{projectToDelete?.title}" 吗?此操作无法撤销。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
取消
</Button>
<Button variant="destructive" onClick={handleDeleteProject}>
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 重命名对话框 */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>重命名画布</DialogTitle>
</DialogHeader>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleRenameProject();
}
}}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
取消
</Button>
<Button onClick={handleRenameProject} disabled={!newTitle.trim()}>
确定
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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 });
}
}

View File

@ -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<string, { width: number; height: number }> = {
"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 }
);
}
}

58
app/api/canvas/route.ts Normal file
View File

@ -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 });
}
}

View File

@ -0,0 +1,18 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "无限画布",
description: "AI 驱动的无限画布创作工具",
};
export default function CanvasEditorLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="h-screen w-screen overflow-hidden bg-background">
{children}
</div>
);
}

750
app/canvas/[id]/page.tsx Normal file
View File

@ -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 (
<div className="h-full flex items-center justify-center bg-muted/30">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">加载中...</span>
</div>
</div>
);
}
return (
<TooltipProvider>
<div className="h-full flex flex-col bg-background">
{/* 顶部工具栏 */}
<div className="h-12 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex items-center justify-between px-4 shrink-0">
{/* 左侧:返回 + 标题 */}
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href="/canvas">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>返回列表</TooltipContent>
</Tooltip>
{isEditingTitle ? (
<Input
value={editTitle}
onChange={(e) => 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
/>
) : (
<button
className="text-sm font-medium hover:text-primary transition-colors"
onClick={() => {
setEditTitle(projectTitle);
setIsEditingTitle(true);
}}
>
{projectTitle}
</button>
)}
</div>
{/* 中间:工具栏 */}
<div className="flex items-center gap-1">
{/* 选择/平移工具 */}
<div className="flex items-center bg-muted rounded-md p-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'select' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={() => setTool('select')}
>
<MousePointer className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>选择工具 (V)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'pan' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={() => setTool('pan')}
>
<Hand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>平移工具 (H)</TooltipContent>
</Tooltip>
</div>
<Separator orientation="vertical" className="h-6 mx-2" />
{/* 撤销/重做 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={undo}
disabled={!canUndo}
>
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>撤销 (Ctrl+Z)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={redo}
disabled={!canRedo}
>
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>重做 (Ctrl+Shift+Z)</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-6 mx-2" />
{/* 缩放 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomOut}
>
<ZoomOut className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>缩小</TooltipContent>
</Tooltip>
<span className="text-xs w-12 text-center tabular-nums">
{Math.round(viewport.zoom * 100)}%
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomIn}
>
<ZoomIn className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>放大</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-6 mx-2" />
{/* 添加节点 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Plus className="h-4 w-4 mr-1" />
添加
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
<DropdownMenuItem onClick={handleAddTextNode}>
<Type className="h-4 w-4 mr-2" />
文本节点
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddImageNode}>
<ImagePlus className="h-4 w-4 mr-2" />
图片节点
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddVideoNode}>
<Video className="h-4 w-4 mr-2" />
视频节点
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 右侧:面板切换 + 保存 */}
<div className="flex items-center gap-2">
{isGenerating && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
生成中...
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setIsPanelOpen(!isPanelOpen)}
>
{isPanelOpen ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeft className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isPanelOpen ? "隐藏面板" : "显示面板"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
className="h-8"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
保存
</Button>
</TooltipTrigger>
<TooltipContent>保存 (Ctrl+S)</TooltipContent>
</Tooltip>
</div>
</div>
{/* 主体区域 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧配置面板 - 参考 lobe-chat-pro */}
<div
className={cn(
"border-r bg-card transition-all duration-300 ease-in-out overflow-hidden",
isPanelOpen ? "w-80" : "w-0"
)}
>
{isPanelOpen && (
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
{/* AI 图片生成 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">AI 图片生成</h3>
</div>
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs">提示词</Label>
<Textarea
placeholder="描述你想要生成的图片..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] text-sm resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">负面提示词</Label>
<Textarea
placeholder="描述你不想出现的内容..."
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
className="min-h-[60px] text-sm resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">尺寸比例</Label>
<Select value={aspectRatio} onValueChange={setAspectRatio}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIZE_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label} ({preset.width}×{preset.height})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">生成数量</Label>
<span className="text-xs text-muted-foreground">{imageCount}</span>
</div>
<Slider
value={[imageCount]}
onValueChange={([v]) => setImageCount(v)}
min={1}
max={4}
step={1}
/>
</div>
<Button
className="w-full"
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
生成中...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
开始生成
</>
)}
</Button>
</div>
</div>
<Separator />
{/* 图片编辑工具 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">图片编辑工具</h3>
</div>
{selectedImageNode ? (
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" className="h-9 justify-start">
<Scissors className="h-4 w-4 mr-2" />
抠图
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Sparkles className="h-4 w-4 mr-2" />
变清晰
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Expand className="h-4 w-4 mr-2" />
扩展
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Maximize2 className="h-4 w-4 mr-2" />
调整
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Move3D className="h-4 w-4 mr-2" />
角度
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Layers className="h-4 w-4 mr-2" />
场景
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground text-center py-4">
选中图片节点以使用编辑工具
</p>
)}
</div>
<Separator />
{/* 快捷操作 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Grid3X3 className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">快捷操作</h3>
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddImageNode}>
<ImagePlus className="h-4 w-4 mr-2" />
添加图片
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddTextNode}>
<Type className="h-4 w-4 mr-2" />
添加文本
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Upload className="h-4 w-4 mr-2" />
上传图片
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Download className="h-4 w-4 mr-2" />
导出
</Button>
</div>
</div>
</div>
</ScrollArea>
)}
</div>
{/* 画布工作区 */}
<div className="flex-1 overflow-hidden bg-muted/30">
<CanvasWorkspace />
</div>
</div>
</div>
</TooltipProvider>
);
}

View File

@ -0,0 +1,270 @@
"use client";
import React, { useRef, useState, useCallback, useMemo } from "react";
import { Rnd } from "react-rnd";
import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode";
import { TextNode } from "./nodes/TextNode";
import { VideoNode } from "./nodes/VideoNode";
import { cn } from "@/lib/utils";
// 性能优化:判断节点是否在视口内
function isNodeInViewport(
node: CanvasNode,
viewport: { x: number; y: number; zoom: number },
containerSize: { width: number; height: number }
): boolean {
const nodeScreenX = node.position.x * viewport.zoom + viewport.x;
const nodeScreenY = node.position.y * viewport.zoom + viewport.y;
const nodeScreenWidth = node.size.width * viewport.zoom;
const nodeScreenHeight = node.size.height * viewport.zoom;
// 添加缓冲区(200px),使即将进入视口的节点也被渲染
const buffer = 200;
return (
nodeScreenX + nodeScreenWidth > -buffer &&
nodeScreenX < containerSize.width + buffer &&
nodeScreenY + nodeScreenHeight > -buffer &&
nodeScreenY < containerSize.height + buffer
);
}
export function CanvasWorkspace() {
const containerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
// Store state
const nodes = useCanvasStore(canvasSelectors.nodes);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
// 性能优化:监听容器大小变化
React.useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
setContainerSize({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
});
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
// 性能优化:只渲染视口内的节点(虚拟化)
const visibleNodes = useMemo(() => {
// 如果节点数量少于 50,直接渲染所有节点
if (nodes.length < 50) return nodes;
return nodes.filter(node =>
isNodeInViewport(node, viewport, containerSize) ||
selectedNodeIds.includes(node.id) // 选中的节点总是渲染
);
}, [nodes, viewport, containerSize, selectedNodeIds]);
// Store actions
const setViewport = useCanvasStore((state) => state.setViewport);
const selectNode = useCanvasStore((state) => state.selectNode);
const clearSelection = useCanvasStore((state) => state.clearSelection);
const moveNode = useCanvasStore((state) => state.moveNode);
const resizeNode = useCanvasStore((state) => state.resizeNode);
const pushHistory = useCanvasStore((state) => state.pushHistory);
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
clearSelection();
}
}, [clearSelection]);
// 处理画布平移开始
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) {
setIsPanning(true);
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
e.preventDefault();
}
}, [tool, viewport]);
// 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
setViewport({
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
});
}
}, [isPanning, panStart, setViewport]);
// 处理画布平移结束
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// 处理滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * delta));
// 以鼠标位置为中心缩放
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const scaleChange = newZoom / viewport.zoom;
const newX = mouseX - (mouseX - viewport.x) * scaleChange;
const newY = mouseY - (mouseY - viewport.y) * scaleChange;
setViewport({ x: newX, y: newY, zoom: newZoom });
}
} else {
// 普通滚动平移
setViewport({
x: viewport.x - e.deltaX,
y: viewport.y - e.deltaY,
});
}
}, [viewport, setViewport]);
// 渲染节点
const renderNode = (node: CanvasNode) => {
const isSelected = selectedNodeIds.includes(node.id);
return (
<Rnd
key={node.id}
position={{
x: node.position.x * viewport.zoom + viewport.x,
y: node.position.y * viewport.zoom + viewport.y,
}}
size={{
width: node.size.width * viewport.zoom,
height: node.size.height * viewport.zoom,
}}
scale={1}
onDragStart={() => {
if (!isSelected) {
selectNode(node.id);
}
}}
onDragStop={(e, d) => {
const newX = (d.x - viewport.x) / viewport.zoom;
const newY = (d.y - viewport.y) / viewport.zoom;
moveNode(node.id, { x: newX, y: newY });
pushHistory();
}}
onResizeStop={(e, direction, ref, delta, position) => {
const newWidth = parseInt(ref.style.width) / viewport.zoom;
const newHeight = parseInt(ref.style.height) / viewport.zoom;
const newX = (position.x - viewport.x) / viewport.zoom;
const newY = (position.y - viewport.y) / viewport.zoom;
resizeNode(node.id, { width: newWidth, height: newHeight });
moveNode(node.id, { x: newX, y: newY });
pushHistory();
}}
bounds="parent"
enableResizing={isSelected && tool === 'select'}
disableDragging={tool !== 'select'}
className={cn(
"absolute",
isSelected && "ring-2 ring-primary ring-offset-2"
)}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
selectNode(node.id, e.shiftKey);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full h-full"
>
{node.type === 'image' && (
<ImageNode node={node} isSelected={isSelected} />
)}
{node.type === 'text' && (
<TextNode node={node} isSelected={isSelected} />
)}
{node.type === 'video' && (
<VideoNode node={node} isSelected={isSelected} />
)}
{node.type === 'message' && (
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
<span className="text-sm">{(node.data as any).content}</span>
</div>
)}
</motion.div>
</Rnd>
);
};
return (
<div
ref={containerRef}
className={cn(
"relative w-full h-full overflow-hidden bg-[#1a1a2e]",
tool === 'pan' && "cursor-grab",
isPanning && "cursor-grabbing"
)}
onClick={handleCanvasClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
>
{/* 网格背景 */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
`,
backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`,
backgroundPosition: `${viewport.x}px ${viewport.y}px`,
}}
/>
{/* 节点层 - 使用虚拟化渲染 */}
<div className="absolute inset-0">
{visibleNodes.map(renderNode)}
</div>
{/* 性能指示器(节点数量大于 50 时显示) */}
{nodes.length >= 50 && (
<div className="absolute top-4 right-4 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1 text-xs text-muted-foreground">
渲染: {visibleNodes.length}/{nodes.length}
</div>
)}
{/* 空状态提示 */}
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center text-muted-foreground">
<p className="text-lg mb-2">画布是空的</p>
<p className="text-sm">点击顶部"添加"按钮开始创作</p>
</div>
</div>
)}
{/* 缩放指示器 */}
<div className="absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-md px-3 py-1.5 text-xs text-muted-foreground">
{Math.round(viewport.zoom * 100)}%
</div>
</div>
);
}

View File

@ -0,0 +1,4 @@
export { CanvasWorkspace } from './CanvasWorkspace';
export { ImageNode } from './nodes/ImageNode';
export { TextNode } from './nodes/TextNode';
export { VideoNode } from './nodes/VideoNode';

View File

@ -0,0 +1,279 @@
"use client";
import React, { useState } from "react";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { imageGenerationService } from "@/services/canvas";
import type { CanvasNode, ImageNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
interface ImageNodeProps {
node: CanvasNode;
isSelected: boolean;
}
export function ImageNode({ node, isSelected }: ImageNodeProps) {
const data = node.data as ImageNodeData;
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
// 处理图片上传
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
updateNode(node.id, {
data: { ...data, src, status: 'completed' },
});
};
reader.readAsDataURL(file);
};
// 处理图片编辑操作 - 集成我们现有的 API
const handleAction = async (action: string) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
// 对话框类型的操作
if (action === 'angle') {
setAngleDialogOpen(true);
return;
}
if (action === 'scene-fusion') {
setSceneFusionDialogOpen(true);
return;
}
if (action === 'resize') {
toast.info("尺寸调整功能即将上线");
return;
}
setIsProcessing(true);
let result;
try {
switch (action) {
case 'rmbg':
result = await imageGenerationService.removeBackground(data.src);
break;
case 'enhance':
result = await imageGenerationService.enhanceImage(data.src);
break;
case 'extend':
result = await imageGenerationService.extendImage(data.src);
break;
default:
setIsProcessing(false);
return;
}
if (result?.success && result.data?.url) {
// 创建新的图片节点显示处理结果
addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: node.size.width, height: node.size.height },
data: {
src: result.data.url,
status: 'completed',
prompt: `${action} 处理结果`,
originalSrc: data.src,
},
connections: [node.id],
});
toast.success(`${action} 处理完成`);
} else {
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
toast.error("处理失败,请重试");
} finally {
setIsProcessing(false);
}
};
// 角度编辑完成回调
const handleAngleEditComplete = (resultUrl: string) => {
// 创建新的图片节点显示结果
addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: node.size.width, height: node.size.height },
data: {
src: resultUrl,
status: 'completed',
prompt: '角度编辑结果',
originalSrc: data.src,
},
connections: [node.id],
});
};
// 下载图片
const handleDownload = () => {
if (data.src) {
const link = document.createElement('a');
link.href = data.src;
link.download = `image-${node.id}.png`;
link.click();
}
};
return (
<>
<div
className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm",
isSelected && "border-primary"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 图片内容 */}
{data.src ? (
<img
src={data.src}
alt={data.prompt || "图片"}
className="w-full h-full object-cover"
draggable={false}
/>
) : data.status === 'generating' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground">生成中...</span>
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive">生成失败</span>
</div>
) : (
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors">
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">点击上传图片</span>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
</label>
)}
{/* 处理中遮罩 */}
{isProcessing && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="h-8 w-8 animate-spin text-white mb-2" />
<span className="text-sm text-white">处理中...</span>
</div>
</div>
)}
{/* 悬浮操作栏 */}
{isHovered && data.src && !isProcessing && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-7 w-7">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleAction('rmbg')}>
<Scissors className="h-4 w-4 mr-2" />
抠图
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('enhance')}>
<Sparkles className="h-4 w-4 mr-2" />
变清晰
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('extend')}>
<Expand className="h-4 w-4 mr-2" />
图片扩展
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('resize')}>
<Maximize2 className="h-4 w-4 mr-2" />
尺寸调整
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('angle')}>
<Move3D className="h-4 w-4 mr-2" />
角度编辑
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('scene-fusion')}>
<Layers className="h-4 w-4 mr-2" />
场景裂变
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* 提示词显示 */}
{data.prompt && isHovered && !isProcessing && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
</div>
)}
</div>
{/* 角度编辑对话框 */}
{data.src && (
<AngleEditDialog
open={angleDialogOpen}
onOpenChange={setAngleDialogOpen}
imageUrl={data.src}
onComplete={handleAngleEditComplete}
/>
)}
{/* 场景裂变对话框 */}
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}
/>
</>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, TextNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface TextNodeProps {
node: CanvasNode;
isSelected: boolean;
}
export function TextNode({ node, isSelected }: TextNodeProps) {
const data = node.data as TextNodeData;
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(data.content);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
// 进入编辑模式时聚焦
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, [isEditing]);
// 保存编辑
const handleSave = () => {
updateNode(node.id, {
data: { ...data, content: editContent },
});
setIsEditing(false);
};
// 取消编辑
const handleCancel = () => {
setEditContent(data.content);
setIsEditing(false);
};
// 双击进入编辑
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsEditing(true);
};
return (
<div
className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm p-3",
isSelected && "border-primary"
)}
onDoubleClick={handleDoubleClick}
>
{isEditing ? (
<textarea
ref={textareaRef}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleSave();
}
if (e.key === 'Escape') {
handleCancel();
}
}}
className="w-full h-full resize-none bg-transparent outline-none text-sm"
style={{
fontSize: data.fontSize || 14,
color: data.color || 'inherit',
fontWeight: data.fontWeight || 'normal',
}}
/>
) : (
<div
className="w-full h-full overflow-auto text-sm whitespace-pre-wrap"
style={{
fontSize: data.fontSize || 14,
color: data.color || 'inherit',
fontWeight: data.fontWeight || 'normal',
}}
>
{data.content || '双击编辑文本'}
</div>
)}
{/* 编辑提示 */}
{!isEditing && isSelected && (
<div className="absolute bottom-1 right-1 text-[10px] text-muted-foreground">
双击编辑
</div>
)}
</div>
);
}

View File

@ -0,0 +1,247 @@
"use client";
import React, { useState, useRef } from "react";
import { Video, Loader2, AlertCircle, MoreVertical, Download, Trash2, Play, Pause, Volume2, VolumeX } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Slider } from "@/components/ui/slider";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface VideoNodeProps {
node: CanvasNode;
isSelected: boolean;
}
export function VideoNode({ node, isSelected }: VideoNodeProps) {
const data = node.data as VideoNodeData;
const videoRef = useRef<HTMLVideoElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const [progress, setProgress] = useState(0);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
// 处理视频上传
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 检查文件大小(限制 100MB)
if (file.size > 100 * 1024 * 1024) {
toast.error("视频文件不能超过 100MB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
updateNode(node.id, {
data: { ...data, src, status: 'completed' },
});
};
reader.readAsDataURL(file);
};
// 播放/暂停
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
// 静音/取消静音
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
// 更新进度
const handleTimeUpdate = () => {
if (videoRef.current) {
const progress = (videoRef.current.currentTime / videoRef.current.duration) * 100;
setProgress(progress);
}
};
// 跳转进度
const handleSeek = (value: number[]) => {
if (videoRef.current) {
const time = (value[0] / 100) * videoRef.current.duration;
videoRef.current.currentTime = time;
setProgress(value[0]);
}
};
// 下载视频
const handleDownload = () => {
if (data.src) {
const link = document.createElement('a');
link.href = data.src;
link.download = `video-${node.id}.mp4`;
link.click();
}
};
return (
<div
className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm",
isSelected && "border-primary"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 视频内容 */}
{data.src ? (
<div className="relative w-full h-full">
<video
ref={videoRef}
src={data.src}
className="w-full h-full object-cover"
muted={isMuted}
loop
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
/>
{/* 播放控制层 */}
{isHovered && (
<div className="absolute inset-0 bg-black/30 flex flex-col justify-end">
{/* 中央播放按钮 */}
<Button
variant="ghost"
size="icon"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-14 w-14 rounded-full bg-black/50 hover:bg-black/70 text-white"
onClick={togglePlay}
>
{isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 ml-1" />
)}
</Button>
{/* 底部控制栏 */}
<div className="p-3 space-y-2">
{/* 进度条 */}
<Slider
value={[progress]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="cursor-pointer"
/>
{/* 控制按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white hover:bg-white/20"
onClick={togglePlay}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white hover:bg-white/20"
onClick={toggleMute}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
) : data.status === 'generating' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground">生成中...</span>
{data.progress && (
<span className="text-xs text-muted-foreground mt-1">{data.progress}%</span>
)}
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive">生成失败</span>
</div>
) : (
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors">
<Video className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">点击上传视频</span>
<input
type="file"
accept="video/*"
className="hidden"
onChange={handleVideoUpload}
/>
</label>
)}
{/* 悬浮操作栏 */}
{isHovered && data.src && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-7 w-7">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* 提示词显示 */}
{data.prompt && isHovered && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
</div>
)}
</div>
);
}

View File

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit } from "lucide-react";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react";
@ -254,6 +254,19 @@ export function TopNavbar({
<span className="text-sm font-medium">图片编辑</span>
</Button>
{/* 无限画布 */}
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
onClick={() => router.push("/canvas")}
type="button"
title="无限画布"
>
<LayoutGrid className="h-4 w-4 text-purple-400" />
<span className="text-sm font-medium">无限画布</span>
</Button>
{/* 我的图册 */}
<Button
variant="ghost"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,13 @@
"when": 1765109627149,
"tag": "0027_add_default_theme",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769247486013,
"tag": "0013_loving_ultimatum",
"breakpoints": true
}
]
}

View File

@ -360,3 +360,46 @@ export const generatedImage = pgTable("GeneratedImage", {
});
export type GeneratedImage = InferSelectModel<typeof generatedImage>;
// ============================================
// 无限画布相关表
// ============================================
// 画布项目表
export const canvasProject = pgTable("CanvasProject", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull().default("未命名画布"),
description: text("description"),
thumbnail: text("thumbnail"),
// 存储完整画布状态(节点、边、视口等)
canvasData: jsonb("canvasData").$type<{
nodes: any[];
edges: any[];
viewport: { x: number; y: number; zoom: number };
}>(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type CanvasProject = InferSelectModel<typeof canvasProject>;
// 画布节点表(可选,用于更细粒度的查询)
export const canvasNode = pgTable("CanvasNode", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
projectId: uuid("projectId")
.notNull()
.references(() => canvasProject.id, { onDelete: "cascade" }),
type: varchar("type", { length: 32 }).notNull(), // 'message' | 'image' | 'video' | 'text' | 'scene-fusion'
position: jsonb("position").$type<{ x: number; y: number }>(),
size: jsonb("size").$type<{ width: number; height: number }>(),
data: jsonb("data"), // 节点具体数据
connections: jsonb("connections").$type<string[]>().default([]),
parentId: uuid("parentId"), // 父节点ID,用于对话上下文
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type CanvasNode = InferSelectModel<typeof canvasNode>;

View File

@ -64,8 +64,9 @@
"embla-carousel-react": "^8.6.0",
"fast-deep-equal": "^3.1.3",
"form-data": "^4.0.5",
"framer-motion": "^11.3.19",
"framer-motion": "^11.18.2",
"geist": "^1.3.1",
"immer": "^11.1.3",
"katex": "^0.16.25",
"lucide-react": "^0.446.0",
"matter-js": "^0.20.0",
@ -89,6 +90,7 @@
"react-data-grid": "7.0.0-beta.47",
"react-dom": "19.0.0-rc-45804af1-20241021",
"react-resizable-panels": "^2.1.7",
"react-rnd": "^10.5.2",
"react-syntax-highlighter": "^15.6.6",
"redis": "^5.0.0",
"resumable-stream": "^2.0.0",
@ -105,7 +107,8 @@
"tokenlens": "1.3.0",
"use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.0",
"zod": "^3.25.76"
"zod": "^3.25.76",
"zustand": "5.0.4"
},
"devDependencies": {
"@biomejs/biome": "2.2.2",

129
pnpm-lock.yaml generated
View File

@ -85,10 +85,10 @@ importers:
version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@react-three/drei':
specifier: ^10.7.7
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
'@react-three/fiber':
specifier: ^9.5.0
version: 9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
version: 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
'@types/form-data':
specifier: ^2.5.2
version: 2.5.2
@ -147,11 +147,14 @@ importers:
specifier: ^4.0.5
version: 4.0.5
framer-motion:
specifier: ^11.3.19
specifier: ^11.18.2
version: 11.18.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
geist:
specifier: ^1.3.1
version: 1.3.1(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
immer:
specifier: ^11.1.3
version: 11.1.3
katex:
specifier: ^0.16.25
version: 0.16.25
@ -221,6 +224,9 @@ importers:
react-resizable-panels:
specifier: ^2.1.7
version: 2.1.7(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
react-rnd:
specifier: ^10.5.2
version: 10.5.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
react-syntax-highlighter:
specifier: ^15.6.6
version: 15.6.6(react@19.0.0-rc-45804af1-20241021)
@ -272,6 +278,9 @@ importers:
zod:
specifier: ^3.25.76
version: 3.25.76
zustand:
specifier: 5.0.4
version: 5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
devDependencies:
'@biomejs/biome':
specifier: 2.2.2
@ -3079,6 +3088,7 @@ packages:
'@vercel/postgres@0.10.0':
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
engines: {node: '>=18.14'}
deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide'
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
@ -3260,6 +3270,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@ -3935,6 +3949,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@11.1.3:
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
import-in-the-middle@1.15.0:
resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==}
@ -4002,6 +4019,9 @@ packages:
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
@ -4132,6 +4152,10 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@ -4418,6 +4442,10 @@ packages:
oauth4webapi@3.3.1:
resolution: {integrity: sha512-ZwX7UqYrP3Lr+Glhca3a1/nF2jqf7VVyJfhGuW5JtrfDUxt0u+IoBPzFjZ2dd7PJGkdM6CFPVVYzuDYKHv101A==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
@ -4587,6 +4615,9 @@ packages:
promise-worker-transferable@1.0.4:
resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-information@5.6.0:
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
@ -4665,6 +4696,12 @@ packages:
'@types/react-dom':
optional: true
re-resizable@6.11.2:
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-data-grid@7.0.0-beta.47:
resolution: {integrity: sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==}
peerDependencies:
@ -4676,6 +4713,15 @@ packages:
peerDependencies:
react: 19.0.0-rc-45804af1-20241021
react-draggable@4.4.6:
resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@ -4708,6 +4754,12 @@ packages:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-rnd@10.5.2:
resolution: {integrity: sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==}
peerDependencies:
react: '>=16.3.0'
react-dom: '>=16.3.0'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@ -5050,6 +5102,9 @@ packages:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -5330,8 +5385,8 @@ packages:
react:
optional: true
zustand@5.0.9:
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
zustand@5.0.4:
resolution: {integrity: sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
@ -7407,12 +7462,12 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
dependencies:
'@babel/runtime': 7.28.4
'@mediapipe/tasks-vision': 0.10.17
'@monogrid/gainmap-js': 3.4.0(three@0.182.0)
'@react-three/fiber': 9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
'@react-three/fiber': 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
'@use-gesture/react': 10.3.1(react@19.0.0-rc-45804af1-20241021)
camera-controls: 3.1.2(three@0.182.0)
cross-env: 7.0.3
@ -7429,10 +7484,10 @@ snapshots:
three-mesh-bvh: 0.8.3(three@0.182.0)
three-stdlib: 2.36.1(three@0.182.0)
troika-three-text: 0.52.4(three@0.182.0)
tunnel-rat: 0.1.2(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)
tunnel-rat: 0.1.2(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
utility-types: 3.11.0
zustand: 5.0.9(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
zustand: 5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
optionalDependencies:
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
transitivePeerDependencies:
@ -7440,7 +7495,7 @@ snapshots:
- '@types/three'
- immer
'@react-three/fiber@9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
'@react-three/fiber@9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
dependencies:
'@babel/runtime': 7.28.4
'@types/webxr': 0.5.24
@ -7453,7 +7508,7 @@ snapshots:
suspend-react: 0.1.3(react@19.0.0-rc-45804af1-20241021)
three: 0.182.0
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
zustand: 5.0.9(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
zustand: 5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
optionalDependencies:
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
transitivePeerDependencies:
@ -8497,6 +8552,8 @@ snapshots:
client-only@0.0.1: {}
clsx@1.2.1: {}
clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
@ -9203,6 +9260,8 @@ snapshots:
immediate@3.0.6: {}
immer@11.1.3: {}
import-in-the-middle@1.15.0:
dependencies:
acorn: 8.15.0
@ -9261,6 +9320,8 @@ snapshots:
jose@6.1.3: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
json-schema@0.4.0: {}
@ -9364,6 +9425,10 @@ snapshots:
longest-streak@3.1.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lowlight@1.20.0:
@ -9883,6 +9948,8 @@ snapshots:
oauth4webapi@3.3.1: {}
object-assign@4.1.1: {}
obuf@1.1.2:
optional: true
@ -10067,6 +10134,12 @@ snapshots:
is-promise: 2.2.2
lie: 3.3.0
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
property-information@5.6.0:
dependencies:
xtend: 4.0.2
@ -10249,6 +10322,11 @@ snapshots:
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
re-resizable@6.11.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
dependencies:
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
react-data-grid@7.0.0-beta.47(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
dependencies:
clsx: 2.1.1
@ -10260,6 +10338,15 @@ snapshots:
react: 19.0.0-rc-45804af1-20241021
scheduler: 0.25.0-rc-45804af1-20241021
react-draggable@4.4.6(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
dependencies:
clsx: 1.2.1
prop-types: 15.8.1
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
react-is@16.13.1: {}
react-markdown@10.1.0(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
dependencies:
'@types/hast': 3.0.4
@ -10302,6 +10389,14 @@ snapshots:
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
react-rnd@10.5.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
dependencies:
re-resizable: 6.11.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
react-draggable: 4.4.6(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
tslib: 2.6.2
react-style-singleton@2.2.3(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
dependencies:
get-nonce: 1.0.1
@ -10716,6 +10811,8 @@ snapshots:
ts-dedent@2.2.0: {}
tslib@2.6.2: {}
tslib@2.8.1: {}
tsx@4.19.3:
@ -10725,9 +10822,9 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-rat@0.1.2(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
tunnel-rat@0.1.2(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021):
dependencies:
zustand: 4.5.7(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)
zustand: 4.5.7(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)
transitivePeerDependencies:
- '@types/react'
- immer
@ -11005,16 +11102,18 @@ snapshots:
zod@4.1.12: {}
zustand@4.5.7(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
zustand@4.5.7(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021):
dependencies:
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
optionalDependencies:
'@types/react': 18.3.18
immer: 11.1.3
react: 19.0.0-rc-45804af1-20241021
zustand@5.0.9(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021)):
zustand@5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021)):
optionalDependencies:
'@types/react': 18.3.18
immer: 11.1.3
react: 19.0.0-rc-45804af1-20241021
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)

260
services/canvas/index.ts Normal file
View File

@ -0,0 +1,260 @@
// 画布服务层
import type { CanvasProject, CanvasState, CanvasNode } from '@/types/canvas';
// API 响应类型
interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
}
// 画布服务
export const canvasService = {
// 获取画布列表
async getProjects(): Promise<ApiResponse<CanvasProject[]>> {
try {
const response = await fetch('/api/canvas');
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '获取画布列表失败' };
}
},
// 获取单个画布
async getProject(id: string): Promise<ApiResponse<CanvasProject>> {
try {
const response = await fetch(`/api/canvas/${id}`);
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '获取画布失败' };
}
},
// 创建画布
async createProject(project: Partial<CanvasProject>): Promise<ApiResponse<CanvasProject>> {
try {
const response = await fetch('/api/canvas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(project),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '创建画布失败' };
}
},
// 更新画布
async updateProject(id: string, updates: Partial<CanvasProject>): Promise<ApiResponse<CanvasProject>> {
try {
const response = await fetch(`/api/canvas/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '更新画布失败' };
}
},
// 删除画布
async deleteProject(id: string): Promise<ApiResponse> {
try {
const response = await fetch(`/api/canvas/${id}`, {
method: 'DELETE',
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '删除画布失败' };
}
},
// 保存画布状态
async saveCanvasState(id: string, canvasData: CanvasState): Promise<ApiResponse> {
try {
const response = await fetch(`/api/canvas/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ canvasData }),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '保存画布状态失败' };
}
},
// 自动保存(防抖)
createAutoSave(projectId: string, delay = 2000) {
let timeoutId: NodeJS.Timeout | null = null;
return (canvasData: CanvasState) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(async () => {
await this.saveCanvasState(projectId, canvasData);
}, delay);
};
},
};
// 图片生成服务
export const imageGenerationService = {
// AI 生成图片
async generateImage(params: {
prompt: string;
negativePrompt?: string;
model?: string;
aspectRatio?: string;
imageCount?: number;
referenceImage?: string;
}): Promise<ApiResponse<{ images: Array<{ thumbnailUrl: string; originalUrl: string }>; model?: string; prompt?: string }>> {
try {
const response = await fetch('/api/canvas/generate/image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '生成图片失败' };
}
},
// 图片抠图
async removeBackground(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/rmbg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '抠图失败' };
}
},
// 图片增强
async enhanceImage(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/enhance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '图片增强失败' };
}
},
// 图片扩展
async extendImage(imageUrl: string, direction?: string): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/extend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, direction }),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '图片扩展失败' };
}
},
// 尺寸调整
async resizeImage(imageUrl: string, width: number, height: number): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/resize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, width, height }),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '尺寸调整失败' };
}
},
// 角度编辑
async editAngle(params: {
imageUrl: string;
horizontalAngle: number;
verticalAngle: number;
zoom: number;
}): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/angle-edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '角度编辑失败' };
}
},
// 场景裂变
async sceneFusion(params: {
productImage: string;
backgroundImages: string[];
productName?: string;
}): Promise<ApiResponse<{ urls: string[] }>> {
try {
const response = await fetch('/api/scene-fusion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '场景裂变失败' };
}
},
};
// 视频生成服务
export const videoGenerationService = {
// 生成视频
async generateVideo(params: {
prompt: string;
model: string;
referenceImage?: string;
duration?: number;
}): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/canvas/generate/video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '生成视频失败' };
}
},
};
export default {
canvas: canvasService,
image: imageGenerationService,
video: videoGenerationService,
};

397
store/canvas/index.ts Normal file
View File

@ -0,0 +1,397 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { devtools, persist } from 'zustand/middleware';
import type {
CanvasNode,
CanvasEdge,
CanvasState,
Viewport,
ToolType,
Position,
Size,
NodeType,
} from '@/types/canvas';
// 生成唯一 ID
const generateId = () => crypto.randomUUID();
// 默认视口
const defaultViewport: Viewport = {
x: 0,
y: 0,
zoom: 1,
};
// Store 状态接口
interface CanvasStore {
// 项目信息
projectId: string | null;
projectTitle: string;
// 画布状态
nodes: CanvasNode[];
edges: CanvasEdge[];
selectedNodeIds: string[];
viewport: Viewport;
tool: ToolType;
// 历史记录(用于撤销/重做)
history: CanvasState[];
historyIndex: number;
// UI 状态
isPanelOpen: boolean;
isGenerating: boolean;
// 项目操作
setProject: (id: string | null, title?: string) => void;
setProjectTitle: (title: string) => void;
// 节点操作
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
deleteNode: (id: string) => void;
deleteSelectedNodes: () => void;
// 选择操作
selectNode: (id: string, multi?: boolean) => void;
selectNodes: (ids: string[]) => void;
clearSelection: () => void;
selectAll: () => void;
// 边操作
addEdge: (edge: Omit<CanvasEdge, 'id'>) => string;
deleteEdge: (id: string) => void;
// 视口操作
setViewport: (viewport: Partial<Viewport>) => void;
zoomIn: () => void;
zoomOut: () => void;
zoomTo: (zoom: number) => void;
fitToScreen: () => void;
centerNode: (nodeId: string) => void;
// 工具操作
setTool: (tool: ToolType) => void;
// 历史操作
undo: () => void;
redo: () => void;
pushHistory: () => void;
// UI 操作
togglePanel: () => void;
setGenerating: (isGenerating: boolean) => void;
// 批量操作
loadCanvas: (state: Partial<CanvasState>) => void;
clearCanvas: () => void;
getCanvasState: () => CanvasState;
// 节点位置操作
moveNode: (id: string, position: Position) => void;
resizeNode: (id: string, size: Size) => void;
// 辅助方法
getNode: (id: string) => CanvasNode | undefined;
getSelectedNodes: () => CanvasNode[];
getNodesByType: (type: NodeType) => CanvasNode[];
}
export const useCanvasStore = create<CanvasStore>()(
devtools(
persist(
immer((set, get) => ({
// 初始状态
projectId: null,
projectTitle: '未命名画布',
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: defaultViewport,
tool: 'select',
history: [],
historyIndex: -1,
isPanelOpen: true,
isGenerating: false,
// 项目操作
setProject: (id, title) => set((state) => {
state.projectId = id;
if (title) state.projectTitle = title;
}),
setProjectTitle: (title) => set((state) => {
state.projectTitle = title;
}),
// 节点操作
addNode: (nodeData) => {
const id = generateId();
const now = new Date();
const node: CanvasNode = {
...nodeData,
id,
createdAt: now,
updatedAt: now,
};
set((state) => {
state.nodes.push(node);
});
// 添加到历史
get().pushHistory();
return id;
},
updateNode: (id, updates) => set((state) => {
const index = state.nodes.findIndex(n => n.id === id);
if (index !== -1) {
state.nodes[index] = {
...state.nodes[index],
...updates,
updatedAt: new Date(),
};
}
}),
deleteNode: (id) => {
set((state) => {
state.nodes = state.nodes.filter(n => n.id !== id);
state.edges = state.edges.filter(e => e.source !== id && e.target !== id);
state.selectedNodeIds = state.selectedNodeIds.filter(nid => nid !== id);
});
get().pushHistory();
},
deleteSelectedNodes: () => {
const { selectedNodeIds } = get();
set((state) => {
state.nodes = state.nodes.filter(n => !selectedNodeIds.includes(n.id));
state.edges = state.edges.filter(
e => !selectedNodeIds.includes(e.source) && !selectedNodeIds.includes(e.target)
);
state.selectedNodeIds = [];
});
get().pushHistory();
},
// 选择操作
selectNode: (id, multi = false) => set((state) => {
if (multi) {
const index = state.selectedNodeIds.indexOf(id);
if (index === -1) {
state.selectedNodeIds.push(id);
} else {
state.selectedNodeIds.splice(index, 1);
}
} else {
state.selectedNodeIds = [id];
}
}),
selectNodes: (ids) => set((state) => {
state.selectedNodeIds = ids;
}),
clearSelection: () => set((state) => {
state.selectedNodeIds = [];
}),
selectAll: () => set((state) => {
state.selectedNodeIds = state.nodes.map(n => n.id);
}),
// 边操作
addEdge: (edgeData) => {
const id = generateId();
const edge: CanvasEdge = { ...edgeData, id };
set((state) => {
// 检查是否已存在相同的边
const exists = state.edges.some(
e => e.source === edge.source && e.target === edge.target
);
if (!exists) {
state.edges.push(edge);
}
});
return id;
},
deleteEdge: (id) => set((state) => {
state.edges = state.edges.filter(e => e.id !== id);
}),
// 视口操作
setViewport: (viewport) => set((state) => {
state.viewport = { ...state.viewport, ...viewport };
}),
zoomIn: () => set((state) => {
state.viewport.zoom = Math.min(state.viewport.zoom * 1.2, 4);
}),
zoomOut: () => set((state) => {
state.viewport.zoom = Math.max(state.viewport.zoom / 1.2, 0.1);
}),
zoomTo: (zoom) => set((state) => {
state.viewport.zoom = Math.max(0.1, Math.min(zoom, 4));
}),
fitToScreen: () => set((state) => {
// TODO: 计算所有节点的边界并调整视口
state.viewport = { x: 0, y: 0, zoom: 1 };
}),
centerNode: (nodeId) => {
const node = get().getNode(nodeId);
if (node) {
set((state) => {
state.viewport.x = -node.position.x - node.size.width / 2;
state.viewport.y = -node.position.y - node.size.height / 2;
});
}
},
// 工具操作
setTool: (tool) => set((state) => {
state.tool = tool;
}),
// 历史操作
undo: () => set((state) => {
if (state.historyIndex > 0) {
state.historyIndex -= 1;
const prevState = state.history[state.historyIndex];
state.nodes = prevState.nodes;
state.edges = prevState.edges;
state.selectedNodeIds = prevState.selectedNodeIds;
}
}),
redo: () => set((state) => {
if (state.historyIndex < state.history.length - 1) {
state.historyIndex += 1;
const nextState = state.history[state.historyIndex];
state.nodes = nextState.nodes;
state.edges = nextState.edges;
state.selectedNodeIds = nextState.selectedNodeIds;
}
}),
pushHistory: () => set((state) => {
const currentState: CanvasState = {
nodes: JSON.parse(JSON.stringify(state.nodes)),
edges: JSON.parse(JSON.stringify(state.edges)),
selectedNodeIds: [...state.selectedNodeIds],
viewport: { ...state.viewport },
tool: state.tool,
};
// 移除当前位置之后的历史
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(currentState);
state.historyIndex = state.history.length - 1;
// 限制历史记录数量
if (state.history.length > 50) {
state.history = state.history.slice(-50);
state.historyIndex = state.history.length - 1;
}
}),
// UI 操作
togglePanel: () => set((state) => {
state.isPanelOpen = !state.isPanelOpen;
}),
setGenerating: (isGenerating) => set((state) => {
state.isGenerating = isGenerating;
}),
// 批量操作
loadCanvas: (canvasState) => set((state) => {
if (canvasState.nodes) state.nodes = canvasState.nodes;
if (canvasState.edges) state.edges = canvasState.edges;
if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds;
if (canvasState.viewport) state.viewport = canvasState.viewport;
if (canvasState.tool) state.tool = canvasState.tool;
}),
clearCanvas: () => set((state) => {
state.nodes = [];
state.edges = [];
state.selectedNodeIds = [];
state.viewport = defaultViewport;
state.history = [];
state.historyIndex = -1;
}),
getCanvasState: () => {
const state = get();
return {
nodes: state.nodes,
edges: state.edges,
selectedNodeIds: state.selectedNodeIds,
viewport: state.viewport,
tool: state.tool,
};
},
// 节点位置操作
moveNode: (id, position) => set((state) => {
const node = state.nodes.find(n => n.id === id);
if (node) {
node.position = position;
node.updatedAt = new Date();
}
}),
resizeNode: (id, size) => set((state) => {
const node = state.nodes.find(n => n.id === id);
if (node) {
node.size = size;
node.updatedAt = new Date();
}
}),
// 辅助方法
getNode: (id) => get().nodes.find(n => n.id === id),
getSelectedNodes: () => {
const { nodes, selectedNodeIds } = get();
return nodes.filter(n => selectedNodeIds.includes(n.id));
},
getNodesByType: (type) => get().nodes.filter(n => n.type === type),
})),
{
name: 'canvas-storage',
partialize: (state) => ({
// 只持久化部分状态
projectId: state.projectId,
projectTitle: state.projectTitle,
}),
}
),
{ name: 'canvas-store' }
)
);
// 导出选择器
export const canvasSelectors = {
nodes: (state: CanvasStore) => state.nodes,
edges: (state: CanvasStore) => state.edges,
selectedNodeIds: (state: CanvasStore) => state.selectedNodeIds,
viewport: (state: CanvasStore) => state.viewport,
tool: (state: CanvasStore) => state.tool,
isGenerating: (state: CanvasStore) => state.isGenerating,
hasSelection: (state: CanvasStore) => state.selectedNodeIds.length > 0,
canUndo: (state: CanvasStore) => state.historyIndex > 0,
canRedo: (state: CanvasStore) => state.historyIndex < state.history.length - 1,
};

162
types/canvas/index.ts Normal file
View File

@ -0,0 +1,162 @@
// 画布节点类型定义
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion';
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
// 画布节点位置
export interface Position {
x: number;
y: number;
}
// 画布节点尺寸
export interface Size {
width: number;
height: number;
}
// 视口状态
export interface Viewport {
x: number;
y: number;
zoom: number;
}
// 消息节点数据
export interface MessageNodeData {
role: 'user' | 'assistant';
content: string;
attachments?: Attachment[];
}
// 图片节点数据
export interface ImageNodeData {
src: string;
prompt?: string;
model?: string;
status: NodeStatus;
metadata?: Record<string, any>;
// 图片编辑相关
originalSrc?: string;
editHistory?: ImageEditAction[];
}
// 视频节点数据
export interface VideoNodeData {
src: string;
thumbnail?: string;
prompt?: string;
model?: string;
status: NodeStatus;
duration?: number;
progress?: number; // 生成进度百分比
coverImage?: string; // 用于生成视频的封面图
}
// 文本节点数据
export interface TextNodeData {
content: string;
fontSize?: number;
color?: string;
fontWeight?: string;
}
// 场景裂变节点数据
export interface SceneFusionNodeData {
productImage: string;
backgroundImages: string[];
productName?: string;
results: string[];
status: NodeStatus;
}
// 画布节点
export interface CanvasNode {
id: string;
type: NodeType;
position: Position;
size: Size;
data: MessageNodeData | ImageNodeData | VideoNodeData | TextNodeData | SceneFusionNodeData;
connections: string[]; // 连接的节点 ID
parentId?: string; // 父节点 ID(用于对话上下文)
createdAt: Date;
updatedAt: Date;
}
// 画布连接线
export interface CanvasEdge {
id: string;
source: string;
target: string;
type?: 'default' | 'conversation' | 'reference';
}
// 画布状态
export interface CanvasState {
nodes: CanvasNode[];
edges: CanvasEdge[];
selectedNodeIds: string[];
viewport: Viewport;
tool: ToolType;
}
// 画布项目
export interface CanvasProject {
id: string;
userId: string;
title: string;
description?: string;
thumbnail?: string;
canvasData: CanvasState;
createdAt: Date;
updatedAt: Date;
}
// 附件类型
export interface Attachment {
type: 'image' | 'file';
url: string;
name?: string;
mimeType?: string;
}
// 图片编辑操作
export interface ImageEditAction {
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate';
timestamp: Date;
params?: Record<string, any>;
resultUrl?: string;
}
// 图片操作菜单项
export interface ImageAction {
label: string;
action: string;
icon?: React.ComponentType<{ className?: string }>;
api?: string;
component?: React.ComponentType<any>;
tool?: string;
}
// AI 生成参数
export interface GenerationParams {
prompt: string;
model: string;
negativePrompt?: string;
size?: string;
style?: string;
referenceImage?: string;
// 图片生成特有
aspectRatio?: string;
// 视频生成特有
duration?: number;
fps?: number;
}
// API 响应类型
export interface CanvasApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
}