feat: switch file list from dialog to inline panel
- Replace dialog-based file list with inline panel in sidebar - Click file list button now switches panel content instead of opening popup - Add CanvasFileListPanel component for embedded file list view - Fix thumbnail property name and type casting issues - Add placeholder handleAction function
This commit is contained in:
parent
9385b6f6d6
commit
676be1c70c
@ -62,10 +62,10 @@ import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import { canvasService, imageGenerationService } from "@/services/canvas";
|
||||
import { CanvasWorkspace } from "@/components/canvas";
|
||||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||||
import { CanvasFileList } from "@/components/canvas/CanvasFileList";
|
||||
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageSquare, FolderOpen } from "lucide-react";
|
||||
import { MessageSquare, FolderOpen, Grid2X2 } from "lucide-react";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@ -116,7 +116,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [isFileListOpen, setIsFileListOpen] = useState(false);
|
||||
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
||||
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
|
||||
|
||||
// 生成配置
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@ -173,17 +174,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
const autoTitleAndThumbnail = async () => {
|
||||
// 如果标题是默认值,且有图片节点
|
||||
if ((projectTitle === "未命名画布" || projectTitle === "新画布") && nodes.length > 0) {
|
||||
const imageNode = nodes.find(n => n.type === 'image' && n.data?.src);
|
||||
if (imageNode?.data?.src) {
|
||||
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, { thumbnailUrl: imageNode.data.src });
|
||||
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: imageNode.data.src })
|
||||
body: JSON.stringify({ imageUrl: imageSrc })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.title) {
|
||||
@ -274,6 +276,16 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// 处理图片操作(占位函数,后续实现具体逻辑)
|
||||
const handleAction = (action: string) => {
|
||||
if (!selectedImageNode) {
|
||||
toast.error("请先选择一张图片");
|
||||
return;
|
||||
}
|
||||
toast.info(`${action} 功能开发中...`);
|
||||
// TODO: 实现具体的图片处理逻辑
|
||||
};
|
||||
|
||||
// AI 生成图片
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
@ -442,10 +454,16 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFileListOpen(true)}
|
||||
className="h-8 gap-2 px-2 font-normal hover:bg-muted rounded-lg"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<Grid2X2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium max-w-[200px] truncate">
|
||||
{projectTitle}
|
||||
</span>
|
||||
@ -492,13 +510,21 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
{/* 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"
|
||||
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">
|
||||
{/* AI 对话内容 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CanvasChatPanel chatId={`canvas-${id}`} onClose={() => setIsPanelOpen(false)} />
|
||||
</div>
|
||||
{panelMode === 'chat' ? (
|
||||
// AI 对话内容
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CanvasChatPanel chatId={`canvas-${id}`} onClose={() => setIsPanelOpen(false)} />
|
||||
</div>
|
||||
) : (
|
||||
// 文件列表
|
||||
<CanvasFileListPanel
|
||||
currentId={id}
|
||||
onClose={() => setPanelMode('chat')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -662,12 +688,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<CanvasFileList
|
||||
open={isFileListOpen}
|
||||
onOpenChange={setIsFileListOpen}
|
||||
currentId={id}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -132,9 +132,9 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="h-12 w-12 rounded-lg bg-muted/50 overflow-hidden shrink-0 border">
|
||||
{project.thumbnailUrl ? (
|
||||
{project.thumbnail ? (
|
||||
<img
|
||||
src={project.thumbnailUrl}
|
||||
src={project.thumbnail}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
184
components/canvas/CanvasFileListPanel.tsx
Normal file
184
components/canvas/CanvasFileListPanel.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
"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, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { canvasService } from "@/services/canvas";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CanvasProject } from "@/types/canvas";
|
||||
|
||||
interface CanvasFileListPanelProps {
|
||||
currentId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelProps) {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<CanvasProject[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载项目列表
|
||||
const loadProjects = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await canvasService.getProjects();
|
||||
if (result.success && result.data) {
|
||||
// 按更新时间降序排序
|
||||
const sorted = result.data.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
setProjects(sorted);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
// 创建新画布
|
||||
const handleCreateNew = async () => {
|
||||
const newId = crypto.randomUUID();
|
||||
router.push(`/canvas/${newId}`);
|
||||
};
|
||||
|
||||
// 切换画布
|
||||
const handleSwitch = (id: string) => {
|
||||
if (id === currentId) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
router.push(`/canvas/${id}`);
|
||||
};
|
||||
|
||||
// 删除画布
|
||||
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 {
|
||||
handleCreateNew();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
||||
<h2 className="font-semibold text-lg">已生成文件列表</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateNew}
|
||||
className="mt-4"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => handleSwitch(project.id)}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all",
|
||||
"hover:bg-muted/50",
|
||||
project.id === currentId
|
||||
? "bg-primary/5 ring-1 ring-primary/20"
|
||||
: "bg-card"
|
||||
)}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="h-14 w-14 rounded-lg bg-muted/50 overflow-hidden shrink-0 border">
|
||||
{project.thumbnail ? (
|
||||
<img
|
||||
src={project.thumbnail}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<FileImage className="h-6 w-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
</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 mt-0.5">
|
||||
{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 transition-opacity"
|
||||
onClick={(e) => handleDelete(e, project.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-center py-6 text-xs text-muted-foreground/50">
|
||||
到底了
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user