feat(组件): 优化侧边栏和消息交互

- 侧边栏新增对话重命名功能
- 优化下拉菜单交互,添加点击外部关闭
- 用户消息气泡新增悬停复制按钮
- 调整菜单文案为英文保持统一
This commit is contained in:
gaoziman 2025-12-20 01:04:56 +08:00
parent 844df69b7c
commit 3112bc1f42
2 changed files with 175 additions and 32 deletions

View File

@ -43,11 +43,29 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
if (isUser) { if (isUser) {
return ( return (
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in"> <div className="flex justify-end items-start gap-3 mb-8 animate-fade-in group">
<div className="max-w-[70%]"> <div className="max-w-[70%] relative">
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-[18px] text-base leading-relaxed"> <div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-[18px] text-base leading-relaxed">
{message.content} {message.content}
</div> </div>
{/* 悬停显示复制按钮 */}
<button
onClick={handleCopy}
className="absolute -bottom-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-md"
title={copied ? '已复制' : '复制'}
>
{copied ? (
<>
<Check size={14} className="text-green-500" />
<span className="text-green-500"></span>
</>
) : (
<>
<Copy size={14} />
<span></span>
</>
)}
</button>
</div> </div>
{user && <Avatar name={user.name} size="md" />} {user && <Avatar name={user.name} size="md" />}
</div> </div>

View File

@ -2,14 +2,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2 } from 'lucide-react'; import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X } from 'lucide-react';
import { UserMenu } from '@/components/ui/UserMenu'; import { UserMenu } from '@/components/ui/UserMenu';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConversations } from '@/hooks/useConversations'; import { useConversations } from '@/hooks/useConversations';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { useAuth } from '@/providers/AuthProvider'; import { useAuth } from '@/providers/AuthProvider';
import type { Conversation } from '@/drizzle/schema'; import type { Conversation } from '@/drizzle/schema';
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
interface SidebarProps { interface SidebarProps {
isOpen?: boolean; isOpen?: boolean;
@ -19,11 +19,39 @@ interface SidebarProps {
export function Sidebar({ isOpen = true }: SidebarProps) { export function Sidebar({ isOpen = true }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { conversations, loading, createConversation, deleteConversation } = useConversations(); const { conversations, loading, createConversation, deleteConversation, updateConversation } = useConversations();
const { settings } = useSettings(); const { settings } = useSettings();
const { user } = useAuth(); const { user } = useAuth();
const [creatingChat, setCreatingChat] = useState(false); const [creatingChat, setCreatingChat] = useState(false);
const [menuOpen, setMenuOpen] = useState<string | null>(null); const [menuOpen, setMenuOpen] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// 聚焦输入框
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingId]);
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(null);
}
};
if (menuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [menuOpen]);
// 创建新对话 // 创建新对话
const handleNewChat = async () => { const handleNewChat = async () => {
@ -60,6 +88,47 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
} }
}; };
// 开始重命名
const handleStartRename = (conversation: Conversation, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setEditingId(conversation.conversationId);
setEditingTitle(conversation.title || '');
setMenuOpen(null);
};
// 提交重命名
const handleSubmitRename = async (conversationId: string) => {
if (!editingTitle.trim() || isSubmitting) return;
try {
setIsSubmitting(true);
await updateConversation(conversationId, { title: editingTitle.trim() });
setEditingId(null);
setEditingTitle('');
} catch (error) {
console.error('Failed to rename conversation:', error);
} finally {
setIsSubmitting(false);
}
};
// 取消重命名
const handleCancelRename = () => {
setEditingId(null);
setEditingTitle('');
};
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent, conversationId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmitRename(conversationId);
} else if (e.key === 'Escape') {
handleCancelRename();
}
};
// 按时间分组对话 // 按时间分组对话
const groupedConversations = groupConversationsByTime(conversations); const groupedConversations = groupConversationsByTime(conversations);
@ -111,47 +180,103 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
</h2> </h2>
{items.map((conversation) => { {items.map((conversation) => {
const isActive = pathname === `/chat/${conversation.conversationId}`; const isActive = pathname === `/chat/${conversation.conversationId}`;
const isEditing = editingId === conversation.conversationId;
return ( return (
<div <div
key={conversation.conversationId} key={conversation.conversationId}
className="relative group" className="relative group"
> >
<Link {isEditing ? (
href={`/chat/${conversation.conversationId}`} // 编辑模式 - 显示输入框
className={cn( <div className="flex items-center gap-1 px-2 py-1">
'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors truncate pr-8', <input
isActive ref={inputRef}
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]' type="text"
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]' value={editingTitle}
)} onChange={(e) => setEditingTitle(e.target.value)}
> onKeyDown={(e) => handleKeyDown(e, conversation.conversationId)}
{conversation.title} onBlur={() => handleCancelRename()}
</Link> className="flex-1 px-2 py-1 text-sm bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-primary)]"
disabled={isSubmitting}
/>
<button
onMouseDown={(e) => {
e.preventDefault();
handleSubmitRename(conversation.conversationId);
}}
disabled={isSubmitting || !editingTitle.trim()}
className="p-1 text-green-500 hover:bg-[var(--color-bg-hover)] rounded disabled:opacity-50"
title="确认"
>
{isSubmitting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Check size={14} />
)}
</button>
<button
onMouseDown={(e) => {
e.preventDefault();
handleCancelRename();
}}
disabled={isSubmitting}
className="p-1 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded disabled:opacity-50"
title="取消"
>
<X size={14} />
</button>
</div>
) : (
// 正常模式 - 显示链接
<Link
href={`/chat/${conversation.conversationId}`}
className={cn(
'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors truncate pr-8',
isActive
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
{conversation.title}
</Link>
)}
{/* 更多操作按钮 */} {/* 更多操作按钮 - 非编辑模式时显示 */}
<button {!isEditing && (
onClick={(e) => { <button
e.preventDefault(); onClick={(e) => {
e.stopPropagation(); e.preventDefault();
setMenuOpen(menuOpen === conversation.conversationId ? null : conversation.conversationId); 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', className={cn(
'hover:bg-[var(--color-bg-hover)]' '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> <MoreHorizontal size={14} className="text-[var(--color-text-tertiary)]" />
</button>
)}
{/* 下拉菜单 */} {/* 下拉菜单 */}
{menuOpen === conversation.conversationId && ( {menuOpen === conversation.conversationId && (
<div className="absolute right-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]"> <div
ref={menuRef}
className="absolute right-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]"
>
<button
onClick={(e) => handleStartRename(conversation, e)}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Pencil size={14} />
Rename
</button>
<button <button
onClick={(e) => handleDeleteConversation(conversation.conversationId, e)} onClick={(e) => handleDeleteConversation(conversation.conversationId, e)}
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2" className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
> >
<Trash2 size={14} /> <Trash2 size={14} />
Delete
</button> </button>
</div> </div>
)} )}