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(ssh:*)",
|
||||||
"Bash(ping:*)",
|
"Bash(ping:*)",
|
||||||
"Bash(curl:*)",
|
"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,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
Headers,
|
Headers,
|
||||||
|
|
@ -144,4 +145,21 @@ export class ConversationController {
|
||||||
message: 'Conversation ended',
|
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
|
* Generate a title from the first message
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { clsx } from 'clsx';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { useChat } from '../hooks/useChat';
|
import { useChat } from '../hooks/useChat';
|
||||||
|
|
||||||
export function ChatSidebar() {
|
export function ChatSidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { conversations, currentConversationId } = useChatStore();
|
const { conversations, currentConversationId, userId, deleteConversation, toggleSidebar } = useChatStore();
|
||||||
const { createConversation } = useChat();
|
const { createConversation } = useChat();
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleNewChat = async () => {
|
const handleNewChat = async () => {
|
||||||
const conversationId = await createConversation();
|
const conversationId = await createConversation();
|
||||||
|
|
@ -20,17 +22,38 @@ export function ChatSidebar() {
|
||||||
navigate(`/chat/${id}`);
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-secondary-200">
|
<div className="p-4 border-b border-secondary-200">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleNewChat}
|
<button
|
||||||
className="w-full flex items-center justify-center gap-2 btn-primary py-2.5"
|
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>
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Conversation list */}
|
{/* Conversation list */}
|
||||||
|
|
@ -49,7 +72,9 @@ export function ChatSidebar() {
|
||||||
title={conv.title || '新对话'}
|
title={conv.title || '新对话'}
|
||||||
isActive={conv.id === currentConversationId}
|
isActive={conv.id === currentConversationId}
|
||||||
updatedAt={conv.updatedAt}
|
updatedAt={conv.updatedAt}
|
||||||
|
isDeleting={deletingId === conv.id}
|
||||||
onClick={() => handleSelectConversation(conv.id)}
|
onClick={() => handleSelectConversation(conv.id)}
|
||||||
|
onDelete={() => handleDeleteConversation(conv.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -71,14 +96,18 @@ interface ConversationItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
isDeleting: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversationItem({
|
function ConversationItem({
|
||||||
title,
|
title,
|
||||||
isActive,
|
isActive,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
isDeleting,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDelete,
|
||||||
}: ConversationItemProps) {
|
}: ConversationItemProps) {
|
||||||
const formatTime = (dateStr: string) => {
|
const formatTime = (dateStr: string) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
|
@ -106,11 +135,13 @@ function ConversationItem({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={isDeleting}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors group',
|
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors group',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-50 text-primary-700'
|
||||||
: 'hover:bg-secondary-50 text-secondary-700',
|
: 'hover:bg-secondary-50 text-secondary-700',
|
||||||
|
isDeleting && 'opacity-50 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
|
|
@ -123,15 +154,20 @@ function ConversationItem({
|
||||||
<p className="text-sm font-medium truncate">{title}</p>
|
<p className="text-sm font-medium truncate">{title}</p>
|
||||||
<p className="text-xs text-secondary-400">{formatTime(updatedAt)}</p>
|
<p className="text-xs text-secondary-400">{formatTime(updatedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isDeleting ? (
|
||||||
onClick={(e) => {
|
<div className="w-4 h-4 border-2 border-secondary-400 border-t-transparent rounded-full animate-spin" />
|
||||||
e.stopPropagation();
|
) : (
|
||||||
// TODO: Implement delete
|
<button
|
||||||
}}
|
onClick={(e) => {
|
||||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-secondary-200 rounded transition-all"
|
e.stopPropagation();
|
||||||
>
|
onDelete();
|
||||||
<Trash2 className="w-3.5 h-3.5 text-secondary-400" />
|
}}
|
||||||
</button>
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Menu, X } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import { ChatWindow } from '../components/ChatWindow';
|
import { ChatWindow } from '../components/ChatWindow';
|
||||||
import { ChatSidebar } from '../components/ChatSidebar';
|
import { ChatSidebar } from '../components/ChatSidebar';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
|
|
@ -8,7 +10,7 @@ import { useAnonymousAuth } from '@/shared/hooks/useAnonymousAuth';
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { conversationId } = useParams();
|
const { conversationId } = useParams();
|
||||||
const { userId, isLoading: authLoading } = useAnonymousAuth();
|
const { userId, isLoading: authLoading } = useAnonymousAuth();
|
||||||
const { setCurrentConversation, loadConversations } = useChatStore();
|
const { setCurrentConversation, loadConversations, sidebarOpen, toggleSidebar, setSidebarOpen } = useChatStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
|
|
@ -22,6 +24,13 @@ export default function ChatPage() {
|
||||||
}
|
}
|
||||||
}, [conversationId, setCurrentConversation]);
|
}, [conversationId, setCurrentConversation]);
|
||||||
|
|
||||||
|
// Close sidebar on mobile when selecting a conversation
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversationId && window.innerWidth < 768) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
}, [conversationId, setSidebarOpen]);
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
|
@ -34,14 +43,52 @@ export default function ChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen relative">
|
||||||
{/* Sidebar - hidden on mobile */}
|
{/* Mobile overlay */}
|
||||||
<div className="hidden md:block w-72 border-r border-secondary-200 bg-white">
|
{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 />
|
<ChatSidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main chat area */}
|
{/* 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 />
|
<ChatWindow />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,20 @@ interface ChatState {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
setUserId: (id: string) => void;
|
setUserId: (id: string) => void;
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
|
||||||
// Conversations
|
// Conversations
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
currentConversationId: string | null;
|
currentConversationId: string | null;
|
||||||
setConversations: (conversations: Conversation[]) => void;
|
setConversations: (conversations: Conversation[]) => void;
|
||||||
addConversation: (conversation: Conversation) => void;
|
addConversation: (conversation: Conversation) => void;
|
||||||
|
removeConversation: (id: string) => void;
|
||||||
setCurrentConversation: (id: string | null) => void;
|
setCurrentConversation: (id: string | null) => void;
|
||||||
loadConversations: (userId: string) => Promise<void>;
|
loadConversations: (userId: string) => Promise<void>;
|
||||||
|
deleteConversation: (userId: string, conversationId: string) => Promise<boolean>;
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
messages: Record<string, Message[]>;
|
messages: Record<string, Message[]>;
|
||||||
|
|
@ -46,11 +53,16 @@ interface ChatState {
|
||||||
setConnected: (connected: boolean) => void;
|
setConnected: (connected: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>((set) => ({
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||||||
// User
|
// User
|
||||||
userId: null,
|
userId: null,
|
||||||
setUserId: (id) => set({ userId: id }),
|
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
|
||||||
conversations: [],
|
conversations: [],
|
||||||
currentConversationId: null,
|
currentConversationId: null,
|
||||||
|
|
@ -59,6 +71,12 @@ export const useChatStore = create<ChatState>((set) => ({
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
conversations: [conversation, ...state.conversations],
|
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 }),
|
setCurrentConversation: (id) => set({ currentConversationId: id }),
|
||||||
loadConversations: async (userId) => {
|
loadConversations: async (userId) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -75,6 +93,24 @@ export const useChatStore = create<ChatState>((set) => ({
|
||||||
console.error('Failed to load conversations:', error);
|
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
|
||||||
messages: {},
|
messages: {},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue