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

View File

@@ -1,15 +1,32 @@
import { Plus, Search } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAccounts } from '@/hooks/useAccounts';
import { AccountCard } from './AccountCard';
import { AccountDetail } from './AccountDetail';
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() {
const { accountId } = useParams<{ accountId?: string }>();
const navigate = useNavigate();
const { accounts, create, update, remove } = useAccounts();
const { accounts, loading, create, update, remove } = useAccounts();
const [search, setSearch] = useState('');
const selected = accountId ? accounts.find((a) => a.id === accountId) || null : null;
@@ -42,22 +59,22 @@ export function AccountList() {
return (
<div className="flex flex-col h-full">
{/* 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">
<h1 className="text-base font-semibold text-zinc-800"></h1>
<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
value={search}
onChange={(e) => setSearch(e.target.value)}
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>
<button
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} />
@@ -66,8 +83,12 @@ export function AccountList() {
{/* Grid */}
<div className="flex-1 overflow-auto p-6">
{filtered.length > 0 ? (
<div className="grid grid-cols-3 gap-4">
{loading ? (
<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) => (
<AccountCard
key={a.id}
@@ -78,12 +99,25 @@ export function AccountList() {
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-zinc-400">
<p className="text-sm">{search ? '没有匹配的账号' : '暂无账号'}</p>
{!search && (
<button onClick={handleCreate} className="mt-2 text-xs text-indigo-600 hover:underline">
</button>
<div className="flex flex-col items-center justify-center h-full text-zinc-300">
<div className="w-16 h-16 rounded-2xl bg-zinc-50 flex items-center justify-center mb-4">
<Search size={24} strokeWidth={1.5} className="text-zinc-300" />
</div>
{search ? (
<>
<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>
)}

View File

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

View File

@@ -2,6 +2,8 @@ import { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { renderMarkdown } from '@/lib/markdown';
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 type { Message } from '@/types';
@@ -29,6 +31,7 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
const isTool = message.role === 'tool';
const [reasoningOpen, setReasoningOpen] = useState(false);
const { toast } = useToast();
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm();
const state = getState(message, !!isLast, !!isThinking);
const handleCopy = useCallback(() => {
@@ -37,10 +40,15 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
});
}, [message.content, toast]);
const handleDelete = useCallback(() => {
if (!confirm('确定删除这条消息?')) return;
onDelete?.(message.id);
}, [message.id, onDelete]);
const handleDeleteClick = useCallback(async () => {
const ok = await confirm({
title: '删除消息',
description: '确定要删除这条消息吗?',
confirmText: '删除',
variant: 'danger',
});
if (ok) onDelete?.(message.id);
}, [message.id, onDelete, confirm]);
// Tool messages
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">
<Quote size={10} />
</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">
<Trash2 size={10} />
</button>
@@ -153,6 +161,17 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
</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>
);
}

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 { MiddlePanel } from './MiddlePanel';
import { ToastContainer } from '@/components/ui/Toast';
@@ -6,9 +7,24 @@ import { useToast } from '@/hooks/useToast';
export function AppLayout({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
const navigate = useNavigate();
const isChat = pathname.startsWith('/chat');
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 (
<div className="h-screen flex bg-white text-zinc-900 overflow-hidden">
<Sidebar />

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
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 { ScrollArea } from '@/components/ui/scroll-area';
import { ConfirmDialog } from '@/components/ui/confirm';
@@ -16,7 +16,6 @@ export function MiddlePanel() {
const location = useLocation();
const { toast } = useToast();
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm();
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const conversationId = useMemo(() => {
const match = location.pathname.match(/^\/chat\/(.+)$/);
@@ -50,13 +49,20 @@ export function MiddlePanel() {
loadConversations();
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
const handleDeleteClick = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('确定删除这个对话?')) return;
await api.deleteConversation(id);
loadConversations();
toast('对话已删除', 'success');
if (id === conversationId) navigate('/chat');
const ok = await confirm({
title: '删除对话',
description: '确定要删除这个对话吗?此操作无法撤销。',
confirmText: '删除',
variant: 'danger',
});
if (ok) {
await api.deleteConversation(id);
loadConversations();
toast('对话已删除', 'success');
if (id === conversationId) navigate('/chat');
}
};
const formatDate = (d: string) => {
@@ -68,73 +74,166 @@ export function MiddlePanel() {
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 (
<aside className="w-60 flex flex-col border-r border-zinc-100 bg-white">
<div className="p-3 flex items-center gap-2 border-b border-zinc-100/80">
<div className="relative flex-1">
<aside className="w-60 flex flex-col border-r border-zinc-100 bg-zinc-50/40">
{/* Top control zone — elevated card feel */}
<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" />
<Input
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}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{/* New conversation */}
<button
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>
</div>
<ScrollArea className="flex-1">
{conversations.map((conv) => (
<div
key={conv.id}
onClick={() => navigate(`/chat/${conv.id}`)}
className={`group w-full text-left px-3 py-2.5 transition-all duration-150 cursor-pointer
${conv.id === conversationId
? 'bg-indigo-50/60 border-l-2 border-l-indigo-500'
: 'hover:bg-zinc-50/80 border-l-2 border-l-transparent'}`}
>
<div className="flex items-center gap-1.5">
{conv.pinned ? <Pin size={11} className="text-indigo-400 flex-shrink-0" /> : null}
<div className={`text-sm truncate flex-1 ${conv.id === conversationId ? 'text-indigo-700 font-medium' : 'text-zinc-600'}`}>
{conv.title}
</div>
<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)}
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 ? '取消置顶' : '置顶'}
>
<Pin size={12} />
</button>
<button
onClick={(e) => handleDelete(conv.id, e)}
className="p-1 rounded-md text-zinc-300 hover:text-red-500 hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 size={12} />
</button>
</div>
{/* Conversation list — recessed zone */}
<ScrollArea className="flex-1 px-2 py-1.5">
{pinned.length > 0 && (
<>
<p className="px-2 pt-1.5 pb-1 text-[10px] text-zinc-400 font-medium tracking-wider"></p>
{pinned.map((conv) => (
<ConversationItem
key={conv.id}
conv={conv}
isActive={conv.id === conversationId}
onNavigate={() => navigate(`/chat/${conv.id}`)}
onTogglePin={(e) => handleTogglePin(conv.id, e)}
onDelete={(e) => handleDeleteClick(conv.id, e)}
formatDate={formatDate}
/>
))}
<div className="my-1.5 mx-2 border-t border-zinc-200/60" />
</>
)}
{buckets.map((bucket) =>
bucket.items.length > 0 ? (
<div key={bucket.label}>
<p className="px-2 pt-2 pb-1 text-[10px] text-zinc-400 font-medium tracking-wider">{bucket.label}</p>
{bucket.items.map((conv) => (
<ConversationItem
key={conv.id}
conv={conv}
isActive={conv.id === conversationId}
onNavigate={() => navigate(`/chat/${conv.id}`)}
onTogglePin={(e) => handleTogglePin(conv.id, e)}
onDelete={(e) => handleDeleteClick(conv.id, e)}
formatDate={formatDate}
/>
))}
</div>
<div className="mt-0.5">
<span className="text-[10px] text-zinc-300">
{formatDate(conv.updated_at)}
</span>
</div>
</div>
))}
) : null
)}
{conversations.length === 0 && (
<div className="flex flex-col items-center justify-center mt-16 text-zinc-300">
<MessageCircle size={28} className="mb-3 opacity-30" strokeWidth={1.5} />
<div className="flex flex-col items-center justify-center pt-20 text-zinc-300">
<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-[10px] mt-1 text-zinc-300"></p>
</div>
)}
</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>
);
}
/* 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>
);
}