Spaces:
Sleeping
Sleeping
| import { | |
| Plus, | |
| Search, | |
| History, | |
| Folder, | |
| CheckSquare, | |
| MoreHorizontal, | |
| LayoutGrid, | |
| PanelLeftClose, | |
| PanelLeftOpen, | |
| Clock, | |
| Edit2, | |
| Trash2, | |
| Share2, | |
| Star, | |
| ExternalLink, | |
| FolderInput | |
| } from 'lucide-react'; | |
| import { useState, useMemo, useRef, useEffect, memo } from 'react'; | |
| import type { ChatSession } from '../services/chatService'; | |
| import Modal from './Modal'; | |
| interface SidebarProps { | |
| isCollapsed: boolean; | |
| toggleSidebar: () => void; | |
| onNavigate: (view: 'home' | 'detail' | 'original') => void; | |
| activeView: 'home' | 'detail' | 'original'; | |
| onReset: () => void; | |
| sessions: ChatSession[]; | |
| currentSessionId: string | null; | |
| onSelectSession: (id: string) => void; | |
| onRenameSession: (id: string, newTitle: string) => void; | |
| onDeleteSession: (id: string) => void; | |
| } | |
| const formatTime = (timestamp: number) => { | |
| const date = new Date(timestamp); | |
| const now = new Date(); | |
| const diff = now.getTime() - date.getTime(); | |
| if (diff < 60 * 60 * 1000) { | |
| const minutes = Math.floor(diff / (60 * 1000)); | |
| return `${minutes}分钟前`; | |
| } else if (diff < 24 * 60 * 60 * 1000) { | |
| const hours = Math.floor(diff / (60 * 60 * 1000)); | |
| return `${hours}小时前`; | |
| } else { | |
| return `${date.getMonth() + 1}月${date.getDate()}日`; | |
| } | |
| }; | |
| export default memo(function Sidebar({ | |
| isCollapsed, | |
| toggleSidebar, | |
| activeView, | |
| onNavigate, | |
| onReset, | |
| sessions, | |
| currentSessionId, | |
| onSelectSession, | |
| onRenameSession, | |
| onDeleteSession | |
| }: SidebarProps) { | |
| const [tooltip, setTooltip] = useState<{ text: string; top: number; visible: boolean }>({ text: '', top: 0, visible: false }); | |
| // Menu State | |
| const [activeMenuId, setActiveMenuId] = useState<string | null>(null); | |
| const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); | |
| const menuRef = useRef<HTMLDivElement>(null); | |
| // Modal State | |
| const [renameModal, setRenameModal] = useState<{ isOpen: boolean; sessionId: string; currentTitle: string }>({ isOpen: false, sessionId: '', currentTitle: '' }); | |
| const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; sessionId: string }>({ isOpen: false, sessionId: '' }); | |
| const [newTitleInput, setNewTitleInput] = useState(''); | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (menuRef.current && !menuRef.current.contains(event.target as Node)) { | |
| setActiveMenuId(null); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => document.removeEventListener('mousedown', handleClickOutside); | |
| }, []); | |
| const handleMenuClick = (e: React.MouseEvent, sessionId: string) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| // Position menu to the right of the button if possible, or below | |
| // Image shows it to the right or below. Let's align top-right of menu to bottom-right of button | |
| setMenuPosition({ top: rect.bottom + 5, left: rect.right - 180 }); | |
| setActiveMenuId(sessionId); | |
| }; | |
| const openRenameModal = (id: string, currentTitle: string) => { | |
| setNewTitleInput(currentTitle); | |
| setRenameModal({ isOpen: true, sessionId: id, currentTitle }); | |
| setActiveMenuId(null); | |
| }; | |
| const openDeleteModal = (id: string) => { | |
| setDeleteModal({ isOpen: true, sessionId: id }); | |
| setActiveMenuId(null); | |
| }; | |
| const handleRenameConfirm = () => { | |
| if (newTitleInput.trim()) { | |
| onRenameSession(renameModal.sessionId, newTitleInput.trim()); | |
| } | |
| setRenameModal({ isOpen: false, sessionId: '', currentTitle: '' }); | |
| }; | |
| const handleDeleteConfirm = () => { | |
| onDeleteSession(deleteModal.sessionId); | |
| setDeleteModal({ isOpen: false, sessionId: '' }); | |
| }; | |
| // Recent Activity Popover State | |
| const [recentPopover, setRecentPopover] = useState<{ top: number; visible: boolean }>({ top: 0, visible: false }); | |
| const popoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| const showRecentPopover = (e: React.MouseEvent) => { | |
| if (!isCollapsed) return; | |
| if (popoverTimeoutRef.current) clearTimeout(popoverTimeoutRef.current); | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| setRecentPopover({ | |
| top: rect.top, | |
| visible: true | |
| }); | |
| }; | |
| const hideRecentPopover = () => { | |
| popoverTimeoutRef.current = setTimeout(() => { | |
| setRecentPopover(prev => ({ ...prev, visible: false })); | |
| }, 300); | |
| }; | |
| const keepRecentPopover = () => { | |
| if (popoverTimeoutRef.current) clearTimeout(popoverTimeoutRef.current); | |
| }; | |
| useEffect(() => { | |
| return () => { | |
| if (popoverTimeoutRef.current) clearTimeout(popoverTimeoutRef.current); | |
| }; | |
| }, []); | |
| const showTooltip = (e: React.MouseEvent, text: string) => { | |
| if (!isCollapsed) return; | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| setTooltip({ | |
| text, | |
| top: rect.top + rect.height / 2, | |
| visible: true | |
| }); | |
| }; | |
| const hideTooltip = () => { | |
| setTooltip(prev => ({ ...prev, visible: false })); | |
| }; | |
| const menuItems = useMemo(() => [ | |
| { icon: Plus, label: '开启新对话', active: activeView === 'home', onClick: onReset }, | |
| { icon: Search, label: '搜索', active: false }, | |
| { icon: History, label: '历史', active: false }, | |
| { icon: Folder, label: '项目', active: false }, | |
| { icon: CheckSquare, label: '待办任务', active: false }, | |
| { icon: LayoutGrid, label: '知识库', active: false }, | |
| // { icon: FileText, label: '原始样式', active: activeView === 'original', onClick: () => onNavigate('original') }, | |
| ], [activeView, onReset, onNavigate]); | |
| return ( | |
| <> | |
| {/* Mobile Backdrop */} | |
| {!isCollapsed && ( | |
| <div | |
| className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 md:hidden" | |
| onClick={toggleSidebar} | |
| /> | |
| )} | |
| <div | |
| className={`bg-[#F9FAFB] dark:bg-gray-900 text-gray-600 dark:text-gray-400 flex flex-col h-screen border-r border-gray-200 dark:border-gray-800 transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] ${ | |
| isCollapsed | |
| ? 'hidden md:flex w-[68px] relative z-20 flex-shrink-0' | |
| : 'fixed inset-y-0 left-0 z-50 w-[260px] shadow-2xl md:relative md:shadow-none md:z-20 md:flex-shrink-0' | |
| }`} | |
| > | |
| {/* Header */} | |
| <div className="h-14 flex items-center relative pl-4 flex-shrink-0 mb-2"> | |
| <div | |
| onClick={isCollapsed ? toggleSidebar : onReset} | |
| className="group relative flex items-center !cursor-pointer overflow-hidden whitespace-nowrap transition-all duration-300 w-full select-none" | |
| onMouseEnter={(e) => showTooltip(e, '打开侧栏')} | |
| onMouseLeave={hideTooltip} | |
| > | |
| <div className="relative flex items-center justify-center w-9 h-9 flex-shrink-0"> | |
| <img | |
| src="/vite.svg" | |
| alt="Logo" | |
| className={`w-7 h-7 rounded-full transition-all duration-300 !cursor-pointer ${isCollapsed ? 'group-hover:opacity-0 scale-100' : ''}`} | |
| /> | |
| {isCollapsed && ( | |
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-0 group-hover:opacity-100 transition-all duration-300 scale-90 group-hover:scale-100"> | |
| <PanelLeftOpen size={20} className="text-gray-600 dark:text-gray-300" /> | |
| </div> | |
| )} | |
| </div> | |
| <div className={`transition-all duration-300 overflow-hidden whitespace-nowrap flex flex-col justify-center ${ | |
| isCollapsed ? 'w-0 opacity-0 ml-0 translate-x-[-10px]' : 'ml-2 w-40 opacity-100 translate-x-0' | |
| }`}> | |
| <span className="font-semibold text-lg text-gray-900 dark:text-gray-100 leading-tight">Manus-clone-cn</span> | |
| </div> | |
| </div> | |
| <button | |
| onClick={toggleSidebar} | |
| className={`!cursor-pointer absolute right-3 p-1.5 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-gray-800 rounded-md transition-all duration-200 active:scale-90 ${ | |
| isCollapsed ? 'opacity-0 pointer-events-none translate-x-2' : 'opacity-100 translate-x-0' | |
| }`} | |
| aria-label="Collapse sidebar" | |
| > | |
| <PanelLeftClose size={18} /> | |
| </button> | |
| </div> | |
| {/* Menu Items & Recent Activity Container */} | |
| <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> | |
| {/* Static Section: Menu Items + Divider */} | |
| <div className="px-3 space-y-0.5 flex-shrink-0"> | |
| {menuItems.map((item, index) => ( | |
| <button | |
| key={index} | |
| onClick={item.onClick || (() => {})} | |
| className={`!cursor-pointer w-full flex items-center h-[38px] px-3 rounded-lg transition-all duration-300 group relative select-none outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-opacity-50 ${ | |
| item.active | |
| ? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm font-medium' | |
| : 'hover:bg-gray-200/60 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 active:scale-[0.98]' | |
| }`} | |
| onMouseEnter={(e) => showTooltip(e, item.label)} | |
| onMouseLeave={hideTooltip} | |
| > | |
| <item.icon size={18} className={`flex-shrink-0 transition-colors duration-200 ${item.active ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300'}`} /> | |
| <div className={`overflow-hidden whitespace-nowrap transition-all duration-300 flex-1 text-left flex items-center ${ | |
| isCollapsed ? 'w-0 opacity-0 ml-0' : 'ml-3 w-full opacity-100' | |
| }`}> | |
| <span className="text-[14px]">{item.label}</span> | |
| </div> | |
| {!isCollapsed && item.label === '开启新对话'} | |
| </button> | |
| ))} | |
| {/* Divider */} | |
| {/* <div className="py-4 px-2"> | |
| <div className="h-px bg-gray-200 dark:bg-gray-800 w-full"></div> | |
| </div> */} | |
| </div> | |
| {/* Recent Activity Section - Scrollable */} | |
| {!isCollapsed ? ( | |
| <div className="flex-1 flex flex-col min-h-0 px-3 overflow-hidden"> | |
| <div className="text-xs font-semibold text-gray-400 mb-2 mt-2 whitespace-nowrap transition-all duration-300 flex-shrink-0"> | |
| 最近活动 | |
| </div> | |
| <div className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-800 hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-700 -mx-1 px-1"> | |
| {sessions.map((session) => ( | |
| <div key={session.id} className="relative group"> | |
| <button | |
| onClick={() => onSelectSession(session.id)} | |
| className={`!cursor-pointer w-full flex items-center h-[38px] px-3 rounded-lg transition-all duration-300 relative select-none outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-opacity-50 mb-0.5 ${ | |
| currentSessionId === session.id && activeView === 'detail' | |
| ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' | |
| : 'hover:bg-gray-200/60 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 active:scale-[0.98]' | |
| }`} | |
| onMouseEnter={(e) => showTooltip(e, session.title)} | |
| onMouseLeave={hideTooltip} | |
| > | |
| <div className="flex-1 opacity-100 overflow-hidden whitespace-nowrap text-left flex items-center"> | |
| <div className="text-[13px] font-medium truncate leading-tight flex-1">{session.title}</div> | |
| </div> | |
| </button> | |
| <div | |
| className={`absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-md hover:bg-gray-300/50 dark:hover:bg-gray-600 transition-opacity cursor-pointer z-10 ${ | |
| activeMenuId === session.id ? 'opacity-100' : 'opacity-0 group-hover:opacity-100' | |
| }`} | |
| onClick={(e) => handleMenuClick(e, session.id)} | |
| > | |
| <MoreHorizontal size={14} className="text-gray-500 dark:text-gray-400" /> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="mt-2 px-2 flex justify-center flex-shrink-0"> | |
| <button | |
| className="!cursor-pointer w-[38px] h-[38px] flex items-center justify-center rounded-lg hover:bg-gray-200/60 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-all duration-200" | |
| onMouseEnter={showRecentPopover} | |
| onMouseLeave={hideRecentPopover} | |
| > | |
| <Clock size={18} /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="p-3 border-t border-gray-200 dark:border-gray-800"> | |
| <button | |
| className="!cursor-pointer w-full flex items-center h-12 px-2 rounded-lg hover:bg-gray-200/60 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-all duration-200 group select-none active:scale-[0.98]" | |
| onMouseEnter={(e) => showTooltip(e, '个人中心')} | |
| onMouseLeave={hideTooltip} | |
| > | |
| <div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0 shadow-sm ring-2 ring-white dark:ring-gray-900 group-hover:ring-transparent transition-all overflow-hidden"> | |
| <img src="/vite.svg" alt="User" className="w-5 h-5" /> | |
| </div> | |
| <div className={`ml-3 overflow-hidden whitespace-nowrap transition-all duration-300 flex-1 text-left flex flex-col justify-center ${ | |
| isCollapsed ? 'w-0 opacity-0 translate-x-[-10px]' : 'w-full opacity-100 translate-x-0' | |
| }`}> | |
| <div className="text-[13px] font-medium text-gray-900 dark:text-gray-100 leading-tight">By_Admin</div> | |
| <div className="text-[11px] text-gray-500">Pro Plan</div> | |
| </div> | |
| {!isCollapsed && <MoreHorizontal size={16} className="text-gray-400 group-hover:text-gray-600 dark:text-gray-500 transition-colors" />} | |
| </button> | |
| </div> | |
| {/* Recent Activity Popover */} | |
| <div | |
| className={`fixed z-[100] w-64 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-200 dark:border-gray-800 py-2 transition-all duration-200 ${ | |
| recentPopover.visible && isCollapsed ? 'opacity-100 translate-x-0 pointer-events-auto' : 'opacity-0 -translate-x-2 pointer-events-none' | |
| }`} | |
| style={{ left: 74, top: Math.min(recentPopover.top - 20, typeof window !== 'undefined' ? window.innerHeight - 300 : 0) }} | |
| onMouseEnter={keepRecentPopover} | |
| onMouseLeave={hideRecentPopover} | |
| > | |
| <div className="px-4 py-2 border-b border-gray-100 dark:border-gray-800 mb-1"> | |
| <span className="text-sm font-semibold text-gray-900 dark:text-gray-100">最近活动</span> | |
| </div> | |
| <div className="max-h-[300px] overflow-y-auto px-2 space-y-0.5 scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-800"> | |
| {sessions.map(session => ( | |
| <button | |
| key={session.id} | |
| onClick={() => { | |
| onSelectSession(session.id); | |
| setRecentPopover(prev => ({ ...prev, visible: false })); | |
| }} | |
| className={`!cursor-pointer w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${ | |
| currentSessionId === session.id | |
| ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' | |
| : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800' | |
| }`} | |
| > | |
| <div className="font-medium truncate">{session.title}</div> | |
| <div className="text-xs text-gray-400 mt-0.5">{formatTime(session.timestamp)}</div> | |
| </button> | |
| ))} | |
| {sessions.length === 0 && ( | |
| <div className="px-3 py-4 text-center text-xs text-gray-400"> | |
| 暂无活动记录 | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Session Menu */} | |
| {activeMenuId && ( | |
| <div | |
| ref={menuRef} | |
| className="fixed z-[110] w-48 bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-800 py-1" | |
| style={{ top: menuPosition.top, left: menuPosition.left }} | |
| > | |
| <div className="px-1 space-y-0.5"> | |
| <button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left cursor-not-allowed opacity-50"> | |
| <Share2 size={14} className="mr-2" /> | |
| 分享 | |
| </button> | |
| <button | |
| className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left" | |
| onClick={() => { | |
| const session = sessions.find(s => s.id === activeMenuId); | |
| if (session) openRenameModal(activeMenuId, session.title); | |
| }} | |
| > | |
| <Edit2 size={14} className="mr-2" /> | |
| 重命名 | |
| </button> | |
| <button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left cursor-not-allowed opacity-50"> | |
| <Star size={14} className="mr-2" /> | |
| 添加到收藏 | |
| </button> | |
| <button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left cursor-not-allowed opacity-50"> | |
| <ExternalLink size={14} className="mr-2" /> | |
| 在新标签页中打开 | |
| </button> | |
| <div className="relative group/submenu"> | |
| <button className="w-full flex items-center px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors text-left justify-between cursor-not-allowed opacity-50"> | |
| <div className="flex items-center"> | |
| <FolderInput size={14} className="mr-2" /> | |
| 移动到项目 | |
| </div> | |
| </button> | |
| </div> | |
| <div className="my-1 h-px bg-gray-200 dark:bg-gray-800" /> | |
| <button | |
| className="w-full flex items-center px-2 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors text-left" | |
| onClick={() => openDeleteModal(activeMenuId)} | |
| > | |
| <Trash2 size={14} className="mr-2" /> | |
| 删除 | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Modals */} | |
| <Modal | |
| isOpen={renameModal.isOpen} | |
| onClose={() => setRenameModal({ ...renameModal, isOpen: false })} | |
| title="重命名对话" | |
| primaryAction={{ | |
| label: '保存', | |
| onClick: handleRenameConfirm | |
| }} | |
| secondaryAction={{ | |
| label: '取消', | |
| onClick: () => setRenameModal({ ...renameModal, isOpen: false }) | |
| }} | |
| > | |
| <div className="space-y-4"> | |
| <div> | |
| <label htmlFor="title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> | |
| 标题 | |
| </label> | |
| <input | |
| type="text" | |
| id="title" | |
| value={newTitleInput} | |
| onChange={(e) => setNewTitleInput(e.target.value)} | |
| className="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white" | |
| placeholder="输入新的对话标题" | |
| autoFocus | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') handleRenameConfirm(); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| </Modal> | |
| <Modal | |
| isOpen={deleteModal.isOpen} | |
| onClose={() => setDeleteModal({ ...deleteModal, isOpen: false })} | |
| title="删除对话" | |
| primaryAction={{ | |
| label: '删除', | |
| onClick: handleDeleteConfirm, | |
| danger: true | |
| }} | |
| secondaryAction={{ | |
| label: '取消', | |
| onClick: () => setDeleteModal({ ...deleteModal, isOpen: false }) | |
| }} | |
| > | |
| <div className="text-gray-600 dark:text-gray-400"> | |
| 确定要删除这个对话吗?此操作无法撤销。 | |
| </div> | |
| </Modal> | |
| {/* Tooltip */} | |
| <div | |
| className={`fixed z-[100] px-3 py-1.5 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded-md shadow-xl pointer-events-none transform -translate-y-1/2 whitespace-nowrap transition-all duration-200 ${ | |
| tooltip.visible ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2' | |
| }`} | |
| style={{ left: 80, top: tooltip.top }} | |
| > | |
| {tooltip.text} | |
| {/* Arrow */} | |
| <div className="absolute top-1/2 -left-1 w-2 h-2 bg-gray-900 dark:bg-gray-100 transform -translate-y-1/2 rotate-45"></div> | |
| </div> | |
| </div> | |
| </> | |
| ); | |
| }); | |