Files
ai-terminal-assistant/packages/web/src/components/Sidebar.tsx
T
kurihada 20765efe62 feat(web): 添加响应式布局和 PWA 支持
- 实现移动端响应式适配:抽屉式侧边栏、触摸优化、Safe Area 支持
- 添加 PWA 支持:vite-plugin-pwa、manifest、Service Worker 缓存
- 优化移动端输入体验:防缩放、最小触摸目标、键盘适配
2025-12-12 13:56:52 +08:00

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>
</>
);
}