chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
276
client/electron/src/components/ContactPanel.tsx
Normal file
276
client/electron/src/components/ContactPanel.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import type { Contact } from '../types';
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ComposeIcon,
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
ChevronRightIcon,
|
||||
ListViewIcon,
|
||||
CardViewIcon
|
||||
} from './icons';
|
||||
import { WorkCard } from './WorkCard';
|
||||
|
||||
/**
|
||||
* 左侧联系人面板:列出所有 name@path.session,支持
|
||||
* - 点击进入该会话
|
||||
* - 写信(预填收件人为该三维地址)
|
||||
* - 归档(Agent 侧会话归档 + 邮箱界面移除)
|
||||
*/
|
||||
export default function ContactPanel() {
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
const archivedContacts = useContactStore(s => s.archivedContacts);
|
||||
const showArchived = useContactStore(s => s.showArchived);
|
||||
const loading = useContactStore(s => s.loading);
|
||||
const error = useContactStore(s => s.error);
|
||||
const pendingArchive = useContactStore(s => s.pendingArchive);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const toggleArchivedView = useContactStore(s => s.toggleArchivedView);
|
||||
const requestArchive = useContactStore(s => s.requestArchive);
|
||||
const cancelArchive = useContactStore(s => s.cancelArchive);
|
||||
const archive = useContactStore(s => s.archive);
|
||||
const view = useContactStore(s => s.view);
|
||||
const setView = useContactStore(s => s.setView);
|
||||
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
const currentSession = useSessionStore(s => s.currentSession);
|
||||
const clearCurrentMail = useMailStore(s => s.clearCurrentMail);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const showDetail = useUIStore(s => s.showDetail);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, []);
|
||||
|
||||
const open = (c: Contact) => {
|
||||
cancelCompose();
|
||||
clearCurrentMail();
|
||||
selectSession(c.session_id);
|
||||
showDetail(); // 窄屏下切到会话内容栏
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
|
||||
// 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
|
||||
view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'
|
||||
}`}
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">
|
||||
{view === 'card' ? '工作列表' : '联系人'}
|
||||
</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
|
||||
<div className="flex-1" />
|
||||
{/* 视图切换:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」 */}
|
||||
<button
|
||||
onClick={() => setView(view === 'list' ? 'card' : 'list')}
|
||||
title={view === 'list' ? '切换到卡片视图' : '切换到列表视图'}
|
||||
className="tap p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{view === 'list' ? (
|
||||
<CardViewIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ListViewIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleArchivedView}
|
||||
className={`tap text-[11px] px-1.5 py-0.5 rounded ${
|
||||
showArchived ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">加载中</p>
|
||||
)}
|
||||
|
||||
{contacts.map(c =>
|
||||
// 归档确认态两种视图共用同一个确认框:那是个破坏性操作,
|
||||
// 换个视图就换套确认 UI 只会让人对「点了什么」更没底
|
||||
pendingArchive === c.address ? (
|
||||
<ArchiveConfirm
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
onCancel={cancelArchive}
|
||||
onConfirm={() => archive(c)}
|
||||
/>
|
||||
) : view === 'card' ? (
|
||||
<WorkCard
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onArchive={() => requestArchive(c.address)}
|
||||
/>
|
||||
) : (
|
||||
<ContactRow
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onRequestArchive={() => requestArchive(c.address)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{!loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">
|
||||
{view === 'card'
|
||||
? '暂无进行中的工作,发一封邮件即可开始'
|
||||
: '暂无联系人,发一封邮件即可建立'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showArchived && (
|
||||
<div className="pt-3 mt-2 border-t border-gray-200">
|
||||
<p className="px-2 pb-1 text-[11px] font-medium text-gray-400">
|
||||
已归档 {archivedContacts.length}
|
||||
</p>
|
||||
{archivedContacts.map(c => (
|
||||
<div
|
||||
key={c.session_id}
|
||||
className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50"
|
||||
>
|
||||
<p className="text-xs font-mono text-gray-500 truncate">{c.address}</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{c.mail_count} 封 · 已归档
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{archivedContacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-3">无归档会话</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档确认框。
|
||||
*
|
||||
* 列表视图与卡片视图共用:归档是破坏性操作,换个视图就换套确认 UI
|
||||
* 只会让人对「自己点了什么」更没底。
|
||||
*/
|
||||
function ArchiveConfirm({
|
||||
contact,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
contact: Contact;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
|
||||
<p className="text-xs text-gray-800">
|
||||
归档 <span className="font-mono">{contact.address}</span>?
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
确认归档
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactRow({
|
||||
contact,
|
||||
active,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onRequestArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onRequestArchive: () => void;
|
||||
}) {
|
||||
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group px-3 py-2.5 rounded-lg border transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onOpen} className="w-full text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">
|
||||
{contact.agent_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
|
||||
{contact.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{contact.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-600 font-mono truncate">
|
||||
{contact.session_alias || '(未命名会话)'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{contact.mail_count} 封 · {time}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="reveal flex gap-1 mt-1.5">
|
||||
<button
|
||||
onClick={onCompose}
|
||||
title="写信给该地址"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
|
||||
>
|
||||
<ComposeIcon className="w-3 h-3" />
|
||||
写信
|
||||
</button>
|
||||
<button
|
||||
onClick={onRequestArchive}
|
||||
title="归档该 name@path.session"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user