refactor(layout): 重构侧边栏支持实时会话管理

- 集成 useConversations 和 useSettings hooks
- 实现新建会话功能并自动跳转
- 实现删除会话功能及确认交互
- 按时间分组显示会话列表(今天、昨天、更早)
- 添加加载状态和操作菜单
- 优化会话列表的交互体验
This commit is contained in:
gaoziman 2025-12-18 11:30:21 +08:00
parent a213cddf55
commit 3a244eb989

View File

@ -1,21 +1,68 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { Plus, ChevronDown, PanelLeft } from 'lucide-react'; import { Plus, ChevronDown, PanelLeft, Trash2, MoreHorizontal, Loader2 } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ChatHistory, User } from '@/types'; import { useConversations } from '@/hooks/useConversations';
import { useSettings } from '@/hooks/useSettings';
import type { User } from '@/types';
import type { Conversation } from '@/drizzle/schema';
import { useState } from 'react';
interface SidebarProps { interface SidebarProps {
user: User; user: User;
chatHistories: ChatHistory[]; chatHistories?: { id: string; title: string }[]; // 保持向后兼容
isOpen?: boolean; isOpen?: boolean;
onToggle?: () => void; onToggle?: () => void;
} }
export function Sidebar({ user, chatHistories, isOpen = true }: SidebarProps) { export function Sidebar({ user, isOpen = true }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const { conversations, loading, createConversation, deleteConversation } = useConversations();
const { settings } = useSettings();
const [creatingChat, setCreatingChat] = useState(false);
const [menuOpen, setMenuOpen] = useState<string | null>(null);
// 创建新对话
const handleNewChat = async () => {
if (creatingChat) return;
try {
setCreatingChat(true);
const newConversation = await createConversation({
model: settings?.defaultModel || 'claude-sonnet-4-20250514',
tools: settings?.defaultTools || [],
enableThinking: settings?.enableThinking || false,
});
router.push(`/chat/${newConversation.conversationId}`);
} catch (error) {
console.error('Failed to create conversation:', error);
} finally {
setCreatingChat(false);
}
};
// 删除对话
const handleDeleteConversation = async (conversationId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
await deleteConversation(conversationId);
setMenuOpen(null);
// 如果当前正在查看被删除的对话,跳转到首页
if (pathname === `/chat/${conversationId}`) {
router.push('/');
}
} catch (error) {
console.error('Failed to delete conversation:', error);
}
};
// 按时间分组对话
const groupedConversations = groupConversationsByTime(conversations);
return ( return (
<> <>
@ -33,53 +80,103 @@ export function Sidebar({ user, chatHistories, isOpen = true }: SidebarProps) {
{/* 新建对话按钮 */} {/* 新建对话按钮 */}
<div className="px-4 py-2"> <div className="px-4 py-2">
<Link <button
href="/" onClick={handleNewChat}
className="flex items-center gap-2 text-[var(--color-primary)] text-sm font-medium py-2 hover:opacity-80 transition-opacity" disabled={creatingChat}
className="flex items-center gap-2 text-[var(--color-primary)] text-sm font-medium py-2 hover:opacity-80 transition-opacity disabled:opacity-50"
> >
{creatingChat ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Plus size={18} /> <Plus size={18} />
)}
<span>New chat</span> <span>New chat</span>
</Link> </button>
</div>
{/* Recents 标签 */}
<div className="px-4 py-2">
<h2 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">
Recents
</h2>
</div> </div>
{/* 聊天列表 */} {/* 聊天列表 */}
<nav className="flex-1 overflow-y-auto px-2 flex flex-col gap-1"> <nav className="flex-1 overflow-y-auto px-2 flex flex-col gap-1">
{chatHistories.map((chat) => { {loading ? (
const isActive = pathname === `/chat/${chat.id}`; <div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-[var(--color-text-tertiary)]" />
</div>
) : conversations.length === 0 ? (
<div className="px-3 py-4 text-sm text-[var(--color-text-tertiary)] text-center">
No conversations yet
</div>
) : (
Object.entries(groupedConversations).map(([group, items]) => (
<div key={group}>
<h2 className="px-3 py-2 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider">
{group}
</h2>
{items.map((conversation) => {
const isActive = pathname === `/chat/${conversation.conversationId}`;
return ( return (
<div
key={conversation.conversationId}
className="relative group"
>
<Link <Link
key={chat.id} href={`/chat/${conversation.conversationId}`}
href={`/chat/${chat.id}`}
className={cn( className={cn(
'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors truncate', 'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors truncate pr-8',
isActive isActive
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]' ? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)} )}
> >
{chat.title} {conversation.title}
</Link> </Link>
{/* 更多操作按钮 */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(menuOpen === conversation.conversationId ? null : conversation.conversationId);
}}
className={cn(
'absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity',
'hover:bg-[var(--color-bg-hover)]'
)}
>
<MoreHorizontal size={14} className="text-[var(--color-text-tertiary)]" />
</button>
{/* 下拉菜单 */}
{menuOpen === conversation.conversationId && (
<div className="absolute right-0 top-full mt-1 bg-white border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]">
<button
onClick={(e) => handleDeleteConversation(conversation.conversationId, e)}
className="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
); );
})} })}
</div>
))
)}
</nav> </nav>
{/* 用户信息 Footer */} {/* 用户信息 Footer */}
<footer className="p-4 border-t border-[var(--color-border-light)] mt-auto"> <footer className="p-4 border-t border-[var(--color-border-light)] mt-auto">
<div className="flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors"> <Link
href="/settings"
className="flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors"
>
<Avatar name={user.name} size="md" /> <Avatar name={user.name} size="md" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-[var(--color-text-primary)] truncate">{user.email}</div> <div className="text-sm text-[var(--color-text-primary)] truncate">{user.email}</div>
<div className="text-xs text-[var(--color-text-tertiary)] capitalize">{user.plan} plan</div> <div className="text-xs text-[var(--color-text-tertiary)] capitalize">{user.plan} plan</div>
</div> </div>
<ChevronDown size={16} className="text-[var(--color-text-tertiary)]" /> <ChevronDown size={16} className="text-[var(--color-text-tertiary)]" />
</div> </Link>
</footer> </footer>
</aside> </aside>
@ -87,6 +184,7 @@ export function Sidebar({ user, chatHistories, isOpen = true }: SidebarProps) {
{isOpen && ( {isOpen && (
<div <div
className="fixed inset-0 bg-black/20 z-40 md:hidden" className="fixed inset-0 bg-black/20 z-40 md:hidden"
onClick={() => setMenuOpen(null)}
/> />
)} )}
</> </>
@ -105,3 +203,38 @@ export function SidebarToggle({ onClick }: { onClick?: () => void }) {
</button> </button>
); );
} }
// 按时间分组对话
function groupConversationsByTime(conversations: Conversation[]): Record<string, Conversation[]> {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const groups: Record<string, Conversation[]> = {};
conversations.forEach((conversation) => {
const date = new Date(conversation.lastMessageAt || conversation.createdAt || now);
let group: string;
if (date >= today) {
group = 'Today';
} else if (date >= yesterday) {
group = 'Yesterday';
} else if (date >= weekAgo) {
group = 'Past 7 days';
} else if (date >= monthAgo) {
group = 'Past 30 days';
} else {
group = 'Older';
}
if (!groups[group]) {
groups[group] = [];
}
groups[group].push(conversation);
});
return groups;
}