Your Name c7ffcda87b
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
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
2026-01-24 19:48:13 +08:00

290 lines
9.9 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 } 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>
);
}