Your Name 8e82b0ea99
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: use Button with router.push for reliable navigation
- Replace Link components with Button + router.push
- Add e.preventDefault() and e.stopPropagation() to prevent event issues
- Fix file list button click handler in CanvasChatPanel
- Ensures single click navigation works consistently
2026-01-26 21:31:58 +08:00

698 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback, use } from "react";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
Save,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
MousePointer,
Hand,
Plus,
ImagePlus,
Video,
Type,
Loader2,
Scissors,
Sparkles,
Expand,
Maximize2,
Move3D,
Layers,
PanelRightClose,
PanelRight,
Download,
Upload,
Wand2,
Grid3X3,
Settings,
} 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 { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { MessageSquare, FolderOpen, Grid2X2 } from "lucide-react";
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);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
// 生成配置
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]);
// 自动保存逻辑
useEffect(() => {
if (isLoading) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
const canvasData = getCanvasState();
try {
await canvasService.saveCanvasState(id, canvasData);
} catch (error) {
console.error("Auto save failed:", error);
}
setIsSaving(false);
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
}, [nodes, id, isLoading, getCanvasState]);
// 智能命名与缩略图逻辑
useEffect(() => {
const autoTitleAndThumbnail = async () => {
// 如果标题是默认值,且有图片节点
if ((projectTitle === "未命名画布" || projectTitle === "新画布") && nodes.length > 0) {
const imageNode = nodes.find(n => n.type === 'image' && (n.data as any)?.src);
const imageSrc = (imageNode?.data as any)?.src;
if (imageSrc) {
try {
// 1. 设置缩略图
await canvasService.updateProject(id, { thumbnail: imageSrc });
// 2. 智能命名
const res = await fetch('/api/canvas/auto-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: imageSrc })
});
const data = await res.json();
if (data.title) {
setProjectTitle(data.title);
await canvasService.updateProject(id, { title: data.title });
toast.success(`画布已自动命名为:${data.title}`);
}
} catch (error) {
console.error("Auto title failed:", error);
}
}
}
};
// 使用防抖,避免频繁触发
const timer = setTimeout(autoTitleAndThumbnail, 5000); // 5秒后触发,给用户一点时间操作
return () => clearTimeout(timer);
}, [nodes, projectTitle, id, setProjectTitle]);
// 保存画布 (手动触发保留作为备用,但UI上移除)
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: [],
});
};
// 处理图片操作(占位函数,后续实现具体逻辑)
const handleAction = (action: string) => {
if (!selectedImageNode) {
toast.error("请先选择一张图片");
return;
}
toast.info(`${action} 功能开发中...`);
// TODO: 实现具体的图片处理逻辑
};
// 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="relative h-dvh w-full overflow-hidden bg-background">
{/* 1. Canvas Layer (Full Screen) */}
<div className="absolute inset-0 z-0">
<CanvasWorkspace />
</div>
{/* 2. Floating Header (Top Left) */}
<div className="absolute top-16 left-4 z-20 flex items-center gap-2 bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => router.push('/')} className="rounded-lg h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>返回首页</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* 文件列表按钮 & 标题展示 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPanelMode('fileList');
setIsPanelOpen(true);
}}
className={cn(
"h-8 gap-2 px-2 font-normal hover:bg-muted rounded-lg",
panelMode === 'fileList' && isPanelOpen && "bg-muted"
)}
>
<Grid2X2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium max-w-[200px] truncate">
{projectTitle}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>查看文件列表</TooltipContent>
</Tooltip>
{(isGenerating || isSaving) && (
<>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden sm:inline">
{isGenerating ? "生成中..." : "保存中..."}
</span>
</div>
</>
)}
</div>
{/* 3. Floating Actions (Top Right) */}
{!isPanelOpen && (
<div className="absolute top-16 right-4 z-20 flex items-center gap-2">
<div className="bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm flex items-center gap-1">
{/* Panel Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsPanelOpen(!isPanelOpen)}
className="rounded-lg h-8 w-8"
>
<PanelRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>显示面板</TooltipContent>
</Tooltip>
</div>
</div>
)}
{/* 4. Floating Sidebar (Right) */}
<div className={cn(
"absolute top-16 right-4 bottom-4 z-10 w-[400px] transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
)}>
<div className="h-full w-full rounded-2xl border bg-background/95 backdrop-blur-md shadow-2xl overflow-hidden flex flex-col">
{panelMode === 'chat' ? (
// AI 对话内容
<div className="flex-1 overflow-hidden">
<CanvasChatPanel
chatId={`canvas-${id}`}
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode('fileList')}
/>
</div>
) : (
// 文件列表
<CanvasFileListPanel
currentId={id}
onClose={() => setPanelMode('chat')}
/>
)}
</div>
</div>
{/* 5. Floating Bottom Toolbar (Left) */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-20">
<div className="flex flex-col items-center gap-2 p-1.5 rounded-2xl border bg-background/80 backdrop-blur-md shadow-2xl">
{/* Tools: Select, Pan, Add Text, Add Image, Add Video */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'select' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setTool('select')}
className={cn("rounded-xl h-9 w-9", tool === 'select' && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<MousePointer className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">选择 (V)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'pan' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setTool('pan')}
className={cn("rounded-xl h-9 w-9", tool === 'pan' && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<Hand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">平移 (H)</TooltipContent>
</Tooltip>
<Separator orientation="horizontal" className="w-5 my-0.5" />
{/* 快捷工具 - 仅在选中图片时显示 */}
{selectedImageNode ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleAction('rmbg')} className="rounded-xl h-9 w-9">
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">抠图</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleAction('enhance')} className="rounded-xl h-9 w-9">
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">变清晰</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleAction('extend')} className="rounded-xl h-9 w-9">
<Expand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">扩展</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleAction('resize')} className="rounded-xl h-9 w-9">
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">调整</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleAction('angle')} className="rounded-xl h-9 w-9">
<Move3D className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">角度</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleAction('scene-fusion')} className="rounded-xl h-9 w-9">
<Layers className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">场景</TooltipContent>
</Tooltip>
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleAddTextNode} className="rounded-xl h-9 w-9">
<Type className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">添加文本</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleAddImageNode} className="rounded-xl h-9 w-9">
<ImagePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">添加图片</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleAddVideoNode} className="rounded-xl h-9 w-9">
<Video className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">添加视频</TooltipContent>
</Tooltip>
</>
)}
<Separator orientation="horizontal" className="w-5 my-0.5" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={undo} disabled={!canUndo} className="rounded-xl h-9 w-9">
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">撤销</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={redo} disabled={!canRedo} className="rounded-xl h-9 w-9">
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">重做</TooltipContent>
</Tooltip>
<Separator orientation="horizontal" className="w-5 my-0.5" />
{/* Zoom Controls */}
<div className="flex flex-col items-center gap-1">
<Button variant="ghost" size="icon" onClick={zoomIn} className="h-8 w-8 rounded-lg">
<ZoomIn className="h-3.5 w-3.5" />
</Button>
<span className="text-[10px] w-10 text-center tabular-nums font-medium">
{Math.round(viewport.zoom * 100)}%
</span>
<Button variant="ghost" size="icon" onClick={zoomOut} className="h-8 w-8 rounded-lg">
<ZoomOut className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
</TooltipProvider>
);
}