feat(web): add collapsible sidebar and delete conversation
Frontend: - Add sidebarOpen state to chatStore with toggle functionality - Make sidebar collapsible with smooth animation - Add mobile-friendly drawer behavior with overlay - Add toggle button for desktop view - Implement delete conversation functionality with loading state Backend: - Add DELETE /conversations/:id endpoint - Implement deleteConversation service method - Delete messages before conversation (foreign key constraint) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
72e67fa5d9
commit
f12ca7a821
|
|
@ -15,7 +15,8 @@
|
|||
"Bash(ssh:*)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(echo \"No health endpoint\" curl -s https://iconsulting.szaiai.com/api/v1/users/profile -H \"x-user-id: test\")"
|
||||
"Bash(echo \"No health endpoint\" curl -s https://iconsulting.szaiai.com/api/v1/users/profile -H \"x-user-id: test\")",
|
||||
"Bash(scp:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Headers,
|
||||
|
|
@ -144,4 +145,21 @@ export class ConversationController {
|
|||
message: 'Conversation ended',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteConversation(
|
||||
@Headers('x-user-id') userId: string,
|
||||
@Param('id') conversationId: string,
|
||||
) {
|
||||
await this.conversationService.deleteConversation(conversationId, userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Conversation deleted',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,20 @@ export class ConversationService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation and its messages
|
||||
*/
|
||||
async deleteConversation(conversationId: string, userId: string): Promise<void> {
|
||||
// Verify user owns the conversation
|
||||
const conversation = await this.getConversation(conversationId, userId);
|
||||
|
||||
// Delete messages first (due to foreign key constraint)
|
||||
await this.messageRepo.delete({ conversationId: conversation.id });
|
||||
|
||||
// Delete conversation
|
||||
await this.conversationRepo.delete(conversation.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title from the first message
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { Plus, MessageSquare, Trash2, ChevronLeft } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
|
||||
export function ChatSidebar() {
|
||||
const navigate = useNavigate();
|
||||
const { conversations, currentConversationId } = useChatStore();
|
||||
const { conversations, currentConversationId, userId, deleteConversation, toggleSidebar } = useChatStore();
|
||||
const { createConversation } = useChat();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const conversationId = await createConversation();
|
||||
|
|
@ -20,17 +22,38 @@ export function ChatSidebar() {
|
|||
navigate(`/chat/${id}`);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
if (!userId || deletingId) return;
|
||||
|
||||
setDeletingId(id);
|
||||
const success = await deleteConversation(userId, id);
|
||||
setDeletingId(null);
|
||||
|
||||
if (success && currentConversationId === id) {
|
||||
navigate('/chat');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-secondary-200">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="w-full flex items-center justify-center gap-2 btn-primary py-2.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>新建对话</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="flex-1 flex items-center justify-center gap-2 btn-primary py-2.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>新建对话</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="hidden md:flex p-2.5 hover:bg-secondary-100 rounded-lg transition-colors"
|
||||
title="收起侧边栏"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-secondary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
|
|
@ -49,7 +72,9 @@ export function ChatSidebar() {
|
|||
title={conv.title || '新对话'}
|
||||
isActive={conv.id === currentConversationId}
|
||||
updatedAt={conv.updatedAt}
|
||||
isDeleting={deletingId === conv.id}
|
||||
onClick={() => handleSelectConversation(conv.id)}
|
||||
onDelete={() => handleDeleteConversation(conv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -71,14 +96,18 @@ interface ConversationItemProps {
|
|||
title: string;
|
||||
isActive: boolean;
|
||||
updatedAt: string;
|
||||
isDeleting: boolean;
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ConversationItem({
|
||||
title,
|
||||
isActive,
|
||||
updatedAt,
|
||||
isDeleting,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: ConversationItemProps) {
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
|
|
@ -106,11 +135,13 @@ function ConversationItem({
|
|||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isDeleting}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors group',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-secondary-50 text-secondary-700',
|
||||
isDeleting && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<MessageSquare
|
||||
|
|
@ -123,15 +154,20 @@ function ConversationItem({
|
|||
<p className="text-sm font-medium truncate">{title}</p>
|
||||
<p className="text-xs text-secondary-400">{formatTime(updatedAt)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: Implement delete
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-secondary-200 rounded transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-secondary-400" />
|
||||
</button>
|
||||
{isDeleting ? (
|
||||
<div className="w-4 h-4 border-2 border-secondary-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-100 hover:text-red-600 rounded transition-all"
|
||||
title="删除对话"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { ChatWindow } from '../components/ChatWindow';
|
||||
import { ChatSidebar } from '../components/ChatSidebar';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
|
|
@ -8,7 +10,7 @@ import { useAnonymousAuth } from '@/shared/hooks/useAnonymousAuth';
|
|||
export default function ChatPage() {
|
||||
const { conversationId } = useParams();
|
||||
const { userId, isLoading: authLoading } = useAnonymousAuth();
|
||||
const { setCurrentConversation, loadConversations } = useChatStore();
|
||||
const { setCurrentConversation, loadConversations, sidebarOpen, toggleSidebar, setSidebarOpen } = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
|
|
@ -22,6 +24,13 @@ export default function ChatPage() {
|
|||
}
|
||||
}, [conversationId, setCurrentConversation]);
|
||||
|
||||
// Close sidebar on mobile when selecting a conversation
|
||||
useEffect(() => {
|
||||
if (conversationId && window.innerWidth < 768) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}, [conversationId, setSidebarOpen]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
|
|
@ -34,14 +43,52 @@ export default function ChatPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar - hidden on mobile */}
|
||||
<div className="hidden md:block w-72 border-r border-secondary-200 bg-white">
|
||||
<div className="flex h-screen relative">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-20 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed md:relative z-30 h-full w-72 border-r border-secondary-200 bg-white transition-transform duration-300 ease-in-out',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0 md:hidden'
|
||||
)}
|
||||
>
|
||||
<ChatSidebar />
|
||||
</div>
|
||||
|
||||
{/* Main chat area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header with menu button */}
|
||||
<div className="flex items-center gap-3 p-3 border-b border-secondary-200 md:hidden">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<X className="w-5 h-5 text-secondary-600" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5 text-secondary-600" />
|
||||
)}
|
||||
</button>
|
||||
<span className="font-medium text-secondary-800">香港移民咨询</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop toggle button */}
|
||||
{!sidebarOpen && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="hidden md:flex absolute left-0 top-4 z-10 p-2 bg-white border border-secondary-200 rounded-r-lg shadow-sm hover:bg-secondary-50 transition-colors"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-secondary-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ChatWindow />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,13 +21,20 @@ interface ChatState {
|
|||
userId: string | null;
|
||||
setUserId: (id: string) => void;
|
||||
|
||||
// Sidebar
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
// Conversations
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
setConversations: (conversations: Conversation[]) => void;
|
||||
addConversation: (conversation: Conversation) => void;
|
||||
removeConversation: (id: string) => void;
|
||||
setCurrentConversation: (id: string | null) => void;
|
||||
loadConversations: (userId: string) => Promise<void>;
|
||||
deleteConversation: (userId: string, conversationId: string) => Promise<boolean>;
|
||||
|
||||
// Messages
|
||||
messages: Record<string, Message[]>;
|
||||
|
|
@ -46,11 +53,16 @@ interface ChatState {
|
|||
setConnected: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// User
|
||||
userId: null,
|
||||
setUserId: (id) => set({ userId: id }),
|
||||
|
||||
// Sidebar - default open on desktop
|
||||
sidebarOpen: window.innerWidth >= 768,
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
// Conversations
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
|
|
@ -59,6 +71,12 @@ export const useChatStore = create<ChatState>((set) => ({
|
|||
set((state) => ({
|
||||
conversations: [conversation, ...state.conversations],
|
||||
})),
|
||||
removeConversation: (id) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.filter((c) => c.id !== id),
|
||||
currentConversationId:
|
||||
state.currentConversationId === id ? null : state.currentConversationId,
|
||||
})),
|
||||
setCurrentConversation: (id) => set({ currentConversationId: id }),
|
||||
loadConversations: async (userId) => {
|
||||
try {
|
||||
|
|
@ -75,6 +93,24 @@ export const useChatStore = create<ChatState>((set) => ({
|
|||
console.error('Failed to load conversations:', error);
|
||||
}
|
||||
},
|
||||
deleteConversation: async (userId, conversationId) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/conversations/${conversationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-user-id': userId,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
get().removeConversation(conversationId);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Messages
|
||||
messages: {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue