20765efe62
- 实现移动端响应式适配:抽屉式侧边栏、触摸优化、Safe Area 支持 - 添加 PWA 支持:vite-plugin-pwa、manifest、Service Worker 缓存 - 优化移动端输入体验:防缩放、最小触摸目标、键盘适配
179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
/**
|
|
* Sidebar Component
|
|
*
|
|
* 响应式侧边栏:桌面端固定显示,移动端抽屉式菜单
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Plus, MessageSquare, Trash2, RefreshCw, Menu, X } from 'lucide-react';
|
|
import clsx from 'clsx';
|
|
import { listSessions, createSession, deleteSession, type Session } from '../api/client';
|
|
|
|
interface SidebarProps {
|
|
currentSessionId: string | null;
|
|
onSelectSession: (id: string) => void;
|
|
onCreateSession: (session: Session) => void;
|
|
}
|
|
|
|
export function Sidebar({ currentSessionId, onSelectSession, onCreateSession }: SidebarProps) {
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const loadSessions = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const { data } = await listSessions();
|
|
setSessions(data);
|
|
} catch (error) {
|
|
console.error('Failed to load sessions:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
try {
|
|
const { data } = await createSession();
|
|
setSessions((prev) => [data, ...prev]);
|
|
onCreateSession(data);
|
|
setIsOpen(false); // 创建后关闭侧边栏
|
|
} catch (error) {
|
|
console.error('Failed to create session:', error);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await deleteSession(id);
|
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
|
if (currentSessionId === id) {
|
|
const remaining = sessions.filter((s) => s.id !== id);
|
|
if (remaining.length > 0) {
|
|
onSelectSession(remaining[0].id);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete session:', error);
|
|
}
|
|
};
|
|
|
|
const handleSelectSession = (id: string) => {
|
|
onSelectSession(id);
|
|
setIsOpen(false); // 选择后关闭侧边栏
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadSessions();
|
|
}, []);
|
|
|
|
// 点击遮罩层关闭
|
|
const handleOverlayClick = () => {
|
|
setIsOpen(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 移动端菜单按钮 */}
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="fixed top-3 left-3 z-40 p-2 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 transition-colors md:hidden"
|
|
aria-label="Open menu"
|
|
>
|
|
<Menu size={20} />
|
|
</button>
|
|
|
|
{/* 遮罩层 - 仅移动端 */}
|
|
{isOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
|
onClick={handleOverlayClick}
|
|
/>
|
|
)}
|
|
|
|
{/* 侧边栏 */}
|
|
<div
|
|
className={clsx(
|
|
'fixed md:static inset-y-0 left-0 z-50',
|
|
'w-64 bg-gray-800 border-r border-gray-700 flex flex-col',
|
|
'transform transition-transform duration-300 ease-in-out',
|
|
'md:transform-none',
|
|
isOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div className="p-4 border-b border-gray-700">
|
|
<div className="flex items-center justify-between mb-3 md:hidden">
|
|
<span className="text-lg font-semibold">Sessions</span>
|
|
<button
|
|
onClick={() => setIsOpen(false)}
|
|
className="p-1 hover:bg-gray-700 rounded transition-colors"
|
|
aria-label="Close menu"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={handleCreate}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors"
|
|
>
|
|
<Plus size={18} />
|
|
<span>New Chat</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Session List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="p-4 text-center text-gray-500">
|
|
<RefreshCw className="animate-spin inline-block" size={20} />
|
|
</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="p-4 text-center text-gray-500">
|
|
No conversations yet
|
|
</div>
|
|
) : (
|
|
<div className="p-2 space-y-1">
|
|
{sessions.map((session) => (
|
|
<div
|
|
key={session.id}
|
|
onClick={() => handleSelectSession(session.id)}
|
|
className={clsx(
|
|
'flex items-center gap-2 p-3 rounded-lg cursor-pointer group',
|
|
'hover:bg-gray-700 transition-colors',
|
|
'active:bg-gray-600', // 触摸反馈
|
|
currentSessionId === session.id && 'bg-gray-700'
|
|
)}
|
|
>
|
|
<MessageSquare size={18} className="text-gray-400 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm truncate">
|
|
{session.name || `Chat ${session.id.slice(0, 8)}`}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{session.messageCount} messages
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={(e) => handleDelete(session.id, e)}
|
|
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-600 rounded transition-all md:opacity-0"
|
|
aria-label="Delete session"
|
|
>
|
|
<Trash2 size={14} className="text-gray-400" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-gray-700 text-center text-xs text-gray-500">
|
|
AI Assistant v1.0
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|