feat(web): 重构对话列表和资产页面,添加加载骨架屏和确认对话框

- 重构 MiddlePanel 对话列表,按时间分组并优化交互
- 为 AccountList 和 AssetGallery 添加加载骨架屏
- 用确认对话框替换原生 confirm,统一交互体验
- 优化聊天消息组件的视觉样式和细节
- 添加 Escape 键快速返回账户列表导航
- 更新构建资源文件
This commit is contained in:
2026-05-08 00:53:34 +08:00
parent 0fb33b9f57
commit 10f11189f8
10 changed files with 600 additions and 411 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

320
web/client/dist/assets/index-DSKEPThI.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>美图 Agent</title> <title>美图 Agent</title>
<script type="module" crossorigin src="/assets/index-ChelO-mr.js"></script> <script type="module" crossorigin src="/assets/index-DSKEPThI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bd3B3-IW.css"> <link rel="stylesheet" crossorigin href="/assets/index-DXUyFc07.css">
</head> </head>
<body class="bg-white text-zinc-900 antialiased"> <body class="bg-white text-zinc-900 antialiased">
<div id="root"></div> <div id="root"></div>

View File

@@ -1,15 +1,32 @@
import { Plus, Search } from 'lucide-react'; import { Plus, Search } from 'lucide-react';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useAccounts } from '@/hooks/useAccounts'; import { useAccounts } from '@/hooks/useAccounts';
import { AccountCard } from './AccountCard'; import { AccountCard } from './AccountCard';
import { AccountDetail } from './AccountDetail'; import { AccountDetail } from './AccountDetail';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
function AccountCardSkeleton() {
return (
<div className="rounded-xl p-4 border border-zinc-100 bg-white">
<div className="flex items-start justify-between mb-2">
<div className="skeleton h-4 w-24" />
<div className="skeleton h-4 w-10 rounded-full" />
</div>
<div className="skeleton h-3 w-full mb-2" />
<div className="skeleton h-3 w-2/3 mb-3" />
<div className="flex gap-1.5">
<div className="skeleton h-4 w-14 rounded-full" />
<div className="skeleton h-4 w-12 rounded-full" />
</div>
</div>
);
}
export function AccountList() { export function AccountList() {
const { accountId } = useParams<{ accountId?: string }>(); const { accountId } = useParams<{ accountId?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { accounts, create, update, remove } = useAccounts(); const { accounts, loading, create, update, remove } = useAccounts();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const selected = accountId ? accounts.find((a) => a.id === accountId) || null : null; const selected = accountId ? accounts.find((a) => a.id === accountId) || null : null;
@@ -42,22 +59,22 @@ export function AccountList() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 bg-white flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-100 bg-white flex-shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-base font-semibold text-zinc-800"></h1> <h1 className="text-base font-semibold text-zinc-800"></h1>
<div className="relative"> <div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" /> <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-300" />
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="搜索账号..." placeholder="搜索账号..."
className="pl-8 h-8 w-48 text-xs bg-zinc-50 border-zinc-200" className="pl-8 h-8 w-48 text-xs bg-zinc-50/80 border-zinc-100 focus:bg-white"
/> />
</div> </div>
</div> </div>
<button <button
onClick={handleCreate} onClick={handleCreate}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors" className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors shadow-sm shadow-indigo-200/50"
> >
<Plus size={14} /> <Plus size={14} />
@@ -66,8 +83,12 @@ export function AccountList() {
{/* Grid */} {/* Grid */}
<div className="flex-1 overflow-auto p-6"> <div className="flex-1 overflow-auto p-6">
{filtered.length > 0 ? ( {loading ? (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 6 }).map((_, i) => <AccountCardSkeleton key={i} />)}
</div>
) : filtered.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map((a) => ( {filtered.map((a) => (
<AccountCard <AccountCard
key={a.id} key={a.id}
@@ -78,12 +99,25 @@ export function AccountList() {
))} ))}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full text-zinc-400"> <div className="flex flex-col items-center justify-center h-full text-zinc-300">
<p className="text-sm">{search ? '没有匹配的账号' : '暂无账号'}</p> <div className="w-16 h-16 rounded-2xl bg-zinc-50 flex items-center justify-center mb-4">
{!search && ( <Search size={24} strokeWidth={1.5} className="text-zinc-300" />
<button onClick={handleCreate} className="mt-2 text-xs text-indigo-600 hover:underline"> </div>
{search ? (
</button> <>
<p className="text-sm text-zinc-400">{search}</p>
<button onClick={() => setSearch('')} className="mt-2 text-xs text-indigo-500 hover:text-indigo-600">
</button>
</>
) : (
<>
<p className="text-sm text-zinc-400"></p>
<button onClick={handleCreate} className="mt-3 inline-flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors shadow-sm shadow-indigo-200/50">
<Plus size={14} />
</button>
</>
)} )}
</div> </div>
)} )}

View File

@@ -16,6 +16,22 @@ interface ManifestGroup {
type TypeTab = 'all' | 'image' | 'video'; type TypeTab = 'all' | 'image' | 'video';
function AssetGroupSkeleton() {
return (
<div className="mb-6">
<div className="flex items-center gap-3 mb-3">
<div className="skeleton h-4 w-28" />
<div className="skeleton h-3 w-20" />
</div>
<div className="flex gap-2.5">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="w-28 h-28 rounded-xl skeleton" />
))}
</div>
</div>
);
}
export function AssetGallery() { export function AssetGallery() {
const [accountFilter, setAccountFilter] = useState(''); const [accountFilter, setAccountFilter] = useState('');
const [typeTab, setTypeTab] = useState<TypeTab>('all'); const [typeTab, setTypeTab] = useState<TypeTab>('all');
@@ -31,13 +47,11 @@ export function AssetGallery() {
fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {}); fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {});
}, []); }, []);
// Filter by type tab
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (typeTab === 'all') return assets; if (typeTab === 'all') return assets;
return assets.filter((a) => a.type === typeTab); return assets.filter((a) => a.type === typeTab);
}, [assets, typeTab]); }, [assets, typeTab]);
// Group filtered assets by manifest_path
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map = new Map<string, Asset[]>(); const map = new Map<string, Asset[]>();
for (const asset of filtered) { for (const asset of filtered) {
@@ -73,7 +87,6 @@ export function AssetGallery() {
return parts.join(' · '); return parts.join(' · ');
}; };
// Filter manifests by account
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
if (!accountFilter) return [...grouped.entries()]; if (!accountFilter) return [...grouped.entries()];
return [...grouped.entries()].filter(([key]) => { return [...grouped.entries()].filter(([key]) => {
@@ -92,21 +105,20 @@ export function AssetGallery() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-200 bg-white flex-shrink-0"> <div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-100 bg-white flex-shrink-0">
{/* Type tabs */} <div className="flex rounded-lg border border-zinc-100 overflow-hidden bg-zinc-50/50">
<div className="flex rounded-lg border border-zinc-200 overflow-hidden">
{tabs.map(({ key, label, icon: Icon, count }) => ( {tabs.map(({ key, label, icon: Icon, count }) => (
<button <button
key={key} key={key}
onClick={() => setTypeTab(key)} onClick={() => setTypeTab(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-all duration-150
${typeTab === key ${typeTab === key
? 'bg-indigo-50 text-indigo-700 border-r border-zinc-200 last:border-r-0' ? 'bg-white text-indigo-600 shadow-sm'
: 'bg-white text-zinc-500 hover:bg-zinc-50 border-r border-zinc-200 last:border-r-0'}`} : 'text-zinc-400 hover:text-zinc-600'}`}
> >
<Icon size={13} /> <Icon size={13} />
{label} {label}
<span className={`text-[10px] px-1 rounded-full ${typeTab === key ? 'bg-indigo-100 text-indigo-600' : 'bg-zinc-100 text-zinc-400'}`}> <span className={`text-[10px] px-1.5 rounded-full ${typeTab === key ? 'bg-indigo-50 text-indigo-500' : 'bg-zinc-100 text-zinc-400'}`}>
{count} {count}
</span> </span>
</button> </button>
@@ -116,7 +128,7 @@ export function AssetGallery() {
<select <select
value={accountFilter} value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)} onChange={(e) => setAccountFilter(e.target.value)}
className="h-8 rounded-md border border-zinc-200 bg-zinc-50 px-2 text-xs" className="h-8 rounded-lg border border-zinc-100 bg-zinc-50/80 px-2 text-xs hover:border-zinc-200 transition-colors"
> >
<option value=""></option> <option value=""></option>
{accounts.map((a) => ( {accounts.map((a) => (
@@ -125,7 +137,7 @@ export function AssetGallery() {
</select> </select>
<div className="flex-1" /> <div className="flex-1" />
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}> <Button size="sm" variant="outline" className="h-8 text-xs rounded-lg" onClick={handleScan}>
<RefreshCw size={12} className="mr-1" /> <RefreshCw size={12} className="mr-1" />
</Button> </Button>
@@ -134,12 +146,16 @@ export function AssetGallery() {
{/* Content */} {/* Content */}
<div className="flex-1 overflow-auto p-4"> <div className="flex-1 overflow-auto p-4">
{loading ? ( {loading ? (
<p className="text-zinc-400 text-sm text-center mt-8">...</p> <div>
{Array.from({ length: 3 }).map((_, i) => <AssetGroupSkeleton key={i} />)}
</div>
) : filteredGroups.length === 0 ? ( ) : filteredGroups.length === 0 ? (
<div className="flex flex-col items-center justify-center mt-16 text-zinc-400"> <div className="flex flex-col items-center justify-center mt-16 text-zinc-300">
<FolderOpen size={28} className="mb-2 opacity-40" /> <div className="w-16 h-16 rounded-2xl bg-zinc-50 flex items-center justify-center mb-4">
<p className="text-sm">{typeTab === 'all' ? '暂无资产' : `暂无${typeTab === 'image' ? '图片' : '视频'}`}</p> <FolderOpen size={24} strokeWidth={1.5} className="text-zinc-300" />
<p className="text-xs mt-1">"扫描" output </p> </div>
<p className="text-sm text-zinc-400">{typeTab === 'all' ? '暂无资产' : `暂无${typeTab === 'image' ? '图片' : '视频'}`}</p>
<p className="text-xs mt-1 text-zinc-300"> output </p>
</div> </div>
) : ( ) : (
filteredGroups.map(([manifestPath, groupAssets]) => ( filteredGroups.map(([manifestPath, groupAssets]) => (

View File

@@ -2,6 +2,8 @@ import { useState, useCallback } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { renderMarkdown } from '@/lib/markdown'; import { renderMarkdown } from '@/lib/markdown';
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain } from 'lucide-react'; import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain } from 'lucide-react';
import { ConfirmDialog } from '@/components/ui/confirm';
import { useConfirm } from '@/hooks/useConfirm';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import type { Message } from '@/types'; import type { Message } from '@/types';
@@ -29,6 +31,7 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
const isTool = message.role === 'tool'; const isTool = message.role === 'tool';
const [reasoningOpen, setReasoningOpen] = useState(false); const [reasoningOpen, setReasoningOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm();
const state = getState(message, !!isLast, !!isThinking); const state = getState(message, !!isLast, !!isThinking);
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
@@ -37,10 +40,15 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
}); });
}, [message.content, toast]); }, [message.content, toast]);
const handleDelete = useCallback(() => { const handleDeleteClick = useCallback(async () => {
if (!confirm('确定删除这条消息?')) return; const ok = await confirm({
onDelete?.(message.id); title: '删除消息',
}, [message.id, onDelete]); description: '确定要删除这条消息吗?',
confirmText: '删除',
variant: 'danger',
});
if (ok) onDelete?.(message.id);
}, [message.id, onDelete, confirm]);
// Tool messages // Tool messages
if (isTool) { if (isTool) {
@@ -138,7 +146,7 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors"> className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
<Quote size={10} /> <Quote size={10} />
</button> </button>
<button onClick={handleDelete} <button onClick={handleDeleteClick}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors"> className="flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
<Trash2 size={10} /> <Trash2 size={10} />
</button> </button>
@@ -153,6 +161,17 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
</div> </div>
)} )}
</div> </div>
<ConfirmDialog
open={confirmState.open}
title={confirmState.title}
description={confirmState.description}
confirmText={confirmState.confirmText}
cancelText={confirmState.cancelText}
variant={confirmState.variant}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useLocation } from 'react-router-dom'; import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { MiddlePanel } from './MiddlePanel'; import { MiddlePanel } from './MiddlePanel';
import { ToastContainer } from '@/components/ui/Toast'; import { ToastContainer } from '@/components/ui/Toast';
@@ -6,9 +7,24 @@ import { useToast } from '@/hooks/useToast';
export function AppLayout({ children }: { children: React.ReactNode }) { export function AppLayout({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const navigate = useNavigate();
const isChat = pathname.startsWith('/chat'); const isChat = pathname.startsWith('/chat');
const { toasts, dismiss } = useToast(); const { toasts, dismiss } = useToast();
// Keyboard navigation: Escape to go back from detail views
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
if (pathname.startsWith('/accounts/') && pathname !== '/accounts') {
navigate('/accounts');
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [pathname, navigate]);
return ( return (
<div className="h-screen flex bg-white text-zinc-900 overflow-hidden"> <div className="h-screen flex bg-white text-zinc-900 overflow-hidden">
<Sidebar /> <Sidebar />

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { Plus, Pin, Trash2, MessageCircle, Search } from 'lucide-react'; import { Pin, Trash2, MessageCircle, Search, Sparkles } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { ConfirmDialog } from '@/components/ui/confirm'; import { ConfirmDialog } from '@/components/ui/confirm';
@@ -16,7 +16,6 @@ export function MiddlePanel() {
const location = useLocation(); const location = useLocation();
const { toast } = useToast(); const { toast } = useToast();
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm(); const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm();
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const conversationId = useMemo(() => { const conversationId = useMemo(() => {
const match = location.pathname.match(/^\/chat\/(.+)$/); const match = location.pathname.match(/^\/chat\/(.+)$/);
@@ -50,13 +49,20 @@ export function MiddlePanel() {
loadConversations(); loadConversations();
}; };
const handleDelete = async (id: string, e: React.MouseEvent) => { const handleDeleteClick = async (id: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!confirm('确定删除这个对话?')) return; const ok = await confirm({
await api.deleteConversation(id); title: '删除对话',
loadConversations(); description: '确定要删除这个对话吗?此操作无法撤销。',
toast('对话已删除', 'success'); confirmText: '删除',
if (id === conversationId) navigate('/chat'); variant: 'danger',
});
if (ok) {
await api.deleteConversation(id);
loadConversations();
toast('对话已删除', 'success');
if (id === conversationId) navigate('/chat');
}
}; };
const formatDate = (d: string) => { const formatDate = (d: string) => {
@@ -68,73 +74,166 @@ export function MiddlePanel() {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}; };
// Split pinned and unpinned
const pinned = conversations.filter((c) => c.pinned);
const unpinned = conversations.filter((c) => !c.pinned);
// Group unpinned by time bucket
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const weekStart = todayStart - 6 * 86400000;
type Bucket = { label: string; items: typeof unpinned };
const buckets: Bucket[] = [
{ label: '今天', items: [] },
{ label: '昨天', items: [] },
{ label: '7 天内', items: [] },
{ label: '更早', items: [] },
];
for (const c of unpinned) {
const t = new Date(c.updated_at).getTime();
if (t >= todayStart) buckets[0].items.push(c);
else if (t >= yesterdayStart) buckets[1].items.push(c);
else if (t >= weekStart) buckets[2].items.push(c);
else buckets[3].items.push(c);
}
return ( return (
<aside className="w-60 flex flex-col border-r border-zinc-100 bg-white"> <aside className="w-60 flex flex-col border-r border-zinc-100 bg-zinc-50/40">
<div className="p-3 flex items-center gap-2 border-b border-zinc-100/80"> {/* Top control zone — elevated card feel */}
<div className="relative flex-1"> <div className="px-3 pt-3 pb-3 bg-white/80 backdrop-blur-sm border-b border-zinc-100">
{/* Search */}
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-300" /> <Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-300" />
<Input <Input
placeholder="搜索对话..." placeholder="搜索对话..."
className="h-8 text-xs bg-zinc-50/80 border-zinc-100 pl-8 focus:bg-white" className="h-8 text-xs bg-zinc-100/80 border-transparent pl-8 rounded-xl focus:bg-white focus:border-indigo-200 focus:ring-1 focus:ring-indigo-200/50 transition-all"
value={search} value={search}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
/> />
</div> </div>
{/* New conversation */}
<button <button
onClick={() => navigate('/chat')} onClick={() => navigate('/chat')}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors whitespace-nowrap shadow-sm shadow-indigo-200/50" className="w-full mt-2.5 flex items-center gap-2.5 px-3 py-2 rounded-xl bg-gradient-to-r from-indigo-500 to-indigo-600 text-white text-xs font-medium hover:from-indigo-600 hover:to-indigo-700 transition-all duration-200 shadow-md shadow-indigo-200/50 active:scale-[0.98]"
> >
<Plus size={13} /> <div className="w-6 h-6 rounded-lg bg-white/20 flex items-center justify-center flex-shrink-0">
<Sparkles size={13} />
</div>
<span></span>
</button> </button>
</div> </div>
<ScrollArea className="flex-1">
{conversations.map((conv) => ( {/* Conversation list — recessed zone */}
<div <ScrollArea className="flex-1 px-2 py-1.5">
key={conv.id} {pinned.length > 0 && (
onClick={() => navigate(`/chat/${conv.id}`)} <>
className={`group w-full text-left px-3 py-2.5 transition-all duration-150 cursor-pointer <p className="px-2 pt-1.5 pb-1 text-[10px] text-zinc-400 font-medium tracking-wider"></p>
${conv.id === conversationId {pinned.map((conv) => (
? 'bg-indigo-50/60 border-l-2 border-l-indigo-500' <ConversationItem
: 'hover:bg-zinc-50/80 border-l-2 border-l-transparent'}`} key={conv.id}
> conv={conv}
<div className="flex items-center gap-1.5"> isActive={conv.id === conversationId}
{conv.pinned ? <Pin size={11} className="text-indigo-400 flex-shrink-0" /> : null} onNavigate={() => navigate(`/chat/${conv.id}`)}
<div className={`text-sm truncate flex-1 ${conv.id === conversationId ? 'text-indigo-700 font-medium' : 'text-zinc-600'}`}> onTogglePin={(e) => handleTogglePin(conv.id, e)}
{conv.title} onDelete={(e) => handleDeleteClick(conv.id, e)}
</div> formatDate={formatDate}
<div className="opacity-0 group-hover:opacity-100 flex gap-0.5 transition-opacity duration-150 flex-shrink-0"> />
<button ))}
onClick={(e) => handleTogglePin(conv.id, e)} <div className="my-1.5 mx-2 border-t border-zinc-200/60" />
className={`p-1 rounded-md transition-colors ${conv.pinned ? 'text-indigo-400' : 'text-zinc-300 hover:text-zinc-500 hover:bg-zinc-100'}`} </>
title={conv.pinned ? '取消置顶' : '置顶'} )}
> {buckets.map((bucket) =>
<Pin size={12} /> bucket.items.length > 0 ? (
</button> <div key={bucket.label}>
<button <p className="px-2 pt-2 pb-1 text-[10px] text-zinc-400 font-medium tracking-wider">{bucket.label}</p>
onClick={(e) => handleDelete(conv.id, e)} {bucket.items.map((conv) => (
className="p-1 rounded-md text-zinc-300 hover:text-red-500 hover:bg-red-50 transition-colors" <ConversationItem
title="删除" key={conv.id}
> conv={conv}
<Trash2 size={12} /> isActive={conv.id === conversationId}
</button> onNavigate={() => navigate(`/chat/${conv.id}`)}
</div> onTogglePin={(e) => handleTogglePin(conv.id, e)}
onDelete={(e) => handleDeleteClick(conv.id, e)}
formatDate={formatDate}
/>
))}
</div> </div>
<div className="mt-0.5"> ) : null
<span className="text-[10px] text-zinc-300"> )}
{formatDate(conv.updated_at)}
</span>
</div>
</div>
))}
{conversations.length === 0 && ( {conversations.length === 0 && (
<div className="flex flex-col items-center justify-center mt-16 text-zinc-300"> <div className="flex flex-col items-center justify-center pt-20 text-zinc-300">
<MessageCircle size={28} className="mb-3 opacity-30" strokeWidth={1.5} /> <div className="w-12 h-12 rounded-2xl bg-white flex items-center justify-center mb-3 shadow-sm">
<MessageCircle size={20} strokeWidth={1.5} className="text-zinc-300" />
</div>
<p className="text-xs text-zinc-400"></p> <p className="text-xs text-zinc-400"></p>
<p className="text-[10px] mt-1 text-zinc-300"></p>
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
<ConfirmDialog
open={confirmState.open}
title={confirmState.title}
description={confirmState.description}
confirmText={confirmState.confirmText}
cancelText={confirmState.cancelText}
variant={confirmState.variant}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</aside> </aside>
); );
} }
/* Individual conversation bubble item */
function ConversationItem({
conv,
isActive,
onNavigate,
onTogglePin,
onDelete,
formatDate,
}: {
conv: { id: string; title: string; pinned?: number; updated_at: string };
isActive: boolean;
onNavigate: () => void;
onTogglePin: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
formatDate: (d: string) => string;
}) {
return (
<div
onClick={onNavigate}
className={`group my-0.5 rounded-xl px-3 py-2 transition-all duration-200 cursor-pointer
${isActive
? 'bg-white ring-1 ring-indigo-200/60 shadow-sm shadow-indigo-100/30'
: 'hover:bg-white hover:shadow-sm hover:shadow-zinc-200/40'}`}
>
<div className="flex items-center gap-1.5">
{conv.pinned ? <Pin size={10} className="text-indigo-400 flex-shrink-0" /> : null}
<span className={`text-[13px] truncate flex-1 leading-snug ${isActive ? 'text-indigo-700 font-medium' : 'text-zinc-600'}`}>
{conv.title}
</span>
<div className="opacity-0 group-hover:opacity-100 flex gap-0.5 transition-opacity duration-150 flex-shrink-0">
<button
onClick={onTogglePin}
className={`p-0.5 rounded-md transition-colors ${conv.pinned ? 'text-indigo-400' : 'text-zinc-300 hover:text-zinc-500 hover:bg-zinc-100'}`}
title={conv.pinned ? '取消置顶' : '置顶'}
>
<Pin size={12} />
</button>
<button
onClick={onDelete}
className="p-0.5 rounded-md text-zinc-300 hover:text-red-500 hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 size={12} />
</button>
</div>
</div>
<span className="text-[10px] text-zinc-300 mt-0.5 block">{formatDate(conv.updated_at)}</span>
</div>
);
}