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) {
return (
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in">
<div className="max-w-[70%]">
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in group">
<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">
{message.content}
</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>
{user && <Avatar name={user.name} size="md" />}
</div>

View File

@ -2,14 +2,14 @@
import Link from 'next/link';
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 { cn } from '@/lib/utils';
import { useConversations } from '@/hooks/useConversations';
import { useSettings } from '@/hooks/useSettings';
import { useAuth } from '@/providers/AuthProvider';
import type { Conversation } from '@/drizzle/schema';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
interface SidebarProps {
isOpen?: boolean;
@ -19,11 +19,39 @@ interface SidebarProps {
export function Sidebar({ isOpen = true }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const { conversations, loading, createConversation, deleteConversation } = useConversations();
const { conversations, loading, createConversation, deleteConversation, updateConversation } = useConversations();
const { settings } = useSettings();
const { user } = useAuth();
const [creatingChat, setCreatingChat] = useState(false);
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 () => {
@ -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);
@ -111,47 +180,103 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
</h2>
{items.map((conversation) => {
const isActive = pathname === `/chat/${conversation.conversationId}`;
const isEditing = editingId === conversation.conversationId;
return (
<div
key={conversation.conversationId}
className="relative group"
>
<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>
{isEditing ? (
// 编辑模式 - 显示输入框
<div className="flex items-center gap-1 px-2 py-1">
<input
ref={inputRef}
type="text"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, conversation.conversationId)}
onBlur={() => handleCancelRename()}
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
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>
{/* 更多操作按钮 - 非编辑模式时显示 */}
{!isEditing && (
<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-[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
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"
>
<Trash2 size={14} />
Delete
</button>
</div>
)}