ai-chatbot/components/canvas/CanvasFileList.tsx
Your Name c7de64b3f6
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
perf: optimize canvas image loading
2026-04-24 18:35:11 +08:00

224 lines
7.5 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { Trash2, FileImage, Loader2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { canvasService } from "@/services/canvas";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import type { CanvasProjectListItem } from "@/types/canvas";
const PROJECT_LIST_PAGE_SIZE = 24;
interface CanvasFileListProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentId: string;
}
export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileListProps) {
const router = useRouter();
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
// 加载项目列表
const loadProjects = async (nextPage = 1, append = false) => {
if (append) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
}
const result = await canvasService.getProjects("canvas", {
page: nextPage,
limit: PROJECT_LIST_PAGE_SIZE,
});
if (result.success && result.data) {
// 按更新时间降序排序
const sorted = result.data.sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
setProjects((prev) => {
if (!append) return sorted;
const seen = new Set(prev.map((project) => project.id));
return [
...prev,
...sorted.filter((project) => !seen.has(project.id)),
];
});
setPage(nextPage);
setHasMore(Boolean(result.pagination?.hasMore));
}
if (append) {
setIsLoadingMore(false);
} else {
setIsLoading(false);
}
};
useEffect(() => {
if (open) {
setProjects([]);
setPage(1);
setHasMore(false);
loadProjects(1, false);
}
}, [open]);
// 创建新画布
const handleCreateNew = async () => {
// 生成一个新 ID 并跳转,具体的创建逻辑由页面组件处理
const newId = crypto.randomUUID();
router.push(`/canvas/${newId}`);
onOpenChange(false);
};
// 切换画布
const handleSwitch = (id: string) => {
if (id === currentId) {
onOpenChange(false);
return;
}
// 先导航,再关闭弹窗(使用 setTimeout 确保导航生效)
const targetUrl = `/canvas/${id}`;
onOpenChange(false);
// 使用 setTimeout 确保在 React 状态更新完成后再导航
setTimeout(() => {
window.location.assign(targetUrl);
}, 100);
};
// 删除画布
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (!confirm("确定要删除这个画布吗?")) return;
const result = await canvasService.deleteProject(id);
if (result.success) {
setProjects(prev => prev.filter(p => p.id !== id));
toast.success("删除成功");
// 如果删除的是当前画布,跳转到列表第一个或首页
if (id === currentId) {
const next = projects.find(p => p.id !== id);
if (next) {
router.push(`/canvas/${next.id}`);
} else {
router.push('/canvas'); // 或者 handleCreateNew()
}
}
} else {
toast.error("删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden bg-background/95 backdrop-blur-sm">
<DialogHeader className="p-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle>已生成文件列表</DialogTitle>
<Button size="sm" onClick={handleCreateNew} className="h-8">
<Plus className="h-4 w-4 mr-1" />
新建画布
</Button>
</div>
<DialogDescription className="hidden">画布列表</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 p-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileImage className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>暂无画布历史</p>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<div
key={project.id}
onClick={() => handleSwitch(project.id)}
className={cn(
"flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border",
"hover:bg-muted/50 hover:shadow-sm",
project.id === currentId
? "bg-primary/5 border-primary/20 ring-1 ring-primary/10"
: "bg-card border-transparent"
)}
>
{/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
<FileImage className="h-5 w-5 text-muted-foreground/40" />
</div>
{/* 信息 */}
<div className="flex-1 min-w-0">
<h4 className={cn(
"font-medium text-sm truncate",
project.id === currentId && "text-primary"
)}>
{project.title || "未命名画布"}
</h4>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(project.updatedAt), {
addSuffix: true,
locale: zhCN
})}
</p>
</div>
{/* 操作 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
onClick={(e) => handleDelete(e, project.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{hasMore ? (
<Button
className="mt-3 w-full"
disabled={isLoadingMore}
onClick={() => loadProjects(page + 1, true)}
size="sm"
variant="outline"
>
{isLoadingMore ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
加载更多
</Button>
) : (
<div className="text-center py-4 text-xs text-muted-foreground/50">
到底了
</div>
)}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}