328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useMailStore } from '../stores/mailStore';
|
||
import { useSessionStore } from '../stores/sessionStore';
|
||
import { useUIStore } from '../stores/uiStore';
|
||
import type { Mail } from '../types';
|
||
import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups';
|
||
import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons';
|
||
import { participantAddress } from '../lib/replyTarget';
|
||
|
||
export default function MailList() {
|
||
const viewMode = useUIStore(s => s.viewMode);
|
||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||
const showDetail = useUIStore(s => s.showDetail);
|
||
|
||
const inbox = useMailStore(s => s.inbox);
|
||
const sent = useMailStore(s => s.sent);
|
||
const currentMail = useMailStore(s => s.currentMail);
|
||
const selectMail = useMailStore(s => s.selectMail);
|
||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||
const fetchSent = useMailStore(s => s.fetchSent);
|
||
const clearSession = useSessionStore(s => s.clearSession);
|
||
|
||
// 哪些会话组被展开。默认全部折叠 —— 收件箱的问题正是「一次任务的几十封信
|
||
// 淹掉其他任务」,默认展开等于没分组
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||
|
||
useEffect(() => {
|
||
if (viewMode === 'sent') fetchSent();
|
||
else if (viewMode === 'inbox') fetchInbox('all');
|
||
}, [viewMode]);
|
||
|
||
// 切换收件箱/发件箱时收起所有组:两个箱子的会话集合不同,
|
||
// 留着上一个箱子的展开状态会让人以为某个组「自己展开了」
|
||
useEffect(() => {
|
||
setExpanded(new Set());
|
||
}, [viewMode]);
|
||
|
||
const isSent = viewMode === 'sent';
|
||
// 权限请求已经有自己的导航项(授权),收件箱只放要读的内容。
|
||
// 不过滤的后果实测过:一个会话的 17 封权限邮件把另外两个会话的信挤出视野。
|
||
// 发件箱不筛:人发不出权限请求(那是 Agent 发的),筛也筛不掉什么。
|
||
const source = isSent ? sent : splitByPermission(inbox).normal;
|
||
const list = source;
|
||
const groups = groupMailsBySession(list);
|
||
|
||
const toggle = (sessionId: string) => {
|
||
setExpanded(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(sessionId)) next.delete(sessionId);
|
||
else next.add(sessionId);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const pick = (m: Mail) => {
|
||
clearSession();
|
||
cancelCompose();
|
||
selectMail(m);
|
||
// 窄屏下列表与详情共用一栏,选中后要切过去;
|
||
// 宽屏下这个状态不影响渲染(两栏并排),但仍然维护 ——
|
||
// 否则从窄屏拖宽再拖回来,用户会发现自己回到了列表,刚打开的邮件不见了
|
||
showDetail();
|
||
};
|
||
|
||
return (
|
||
<div className="w-full lg:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||
<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">{isSent ? '发件箱' : '收件箱'}</h2>
|
||
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
|
||
「有几件事」,后者只是流量 */}
|
||
<span className="ml-2 text-xs text-gray-500">
|
||
{groups.length > 0 && groups.length !== list.length
|
||
? `${groups.length} 组 · ${list.length} 封`
|
||
: list.length}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||
{groups.map(g =>
|
||
isFlatGroup(g) ? (
|
||
<MailItem
|
||
key={g.latest.mail_id}
|
||
mail={g.latest}
|
||
active={currentMail?.mail_id === g.latest.mail_id}
|
||
showTo={isSent}
|
||
onClick={() => pick(g.latest)}
|
||
/>
|
||
) : (
|
||
<SessionGroup
|
||
key={g.sessionId}
|
||
group={g}
|
||
showTo={isSent}
|
||
open={expanded.has(g.sessionId)}
|
||
onToggle={() => toggle(g.sessionId)}
|
||
currentMailID={currentMail?.mail_id}
|
||
onPick={pick}
|
||
/>
|
||
)
|
||
)}
|
||
{list.length === 0 && (
|
||
<p className="text-xs text-gray-500 text-center py-6">暂无邮件</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 一个会话折叠成的一组。
|
||
*
|
||
* 组头答的是「哪件事、进行到哪」;展开后才是逐封邮件。
|
||
* 权限请求不在这里 —— 它们在单独的「授权」导航项里。
|
||
*/
|
||
function SessionGroup({
|
||
group,
|
||
showTo,
|
||
open,
|
||
onToggle,
|
||
currentMailID,
|
||
onPick
|
||
}: {
|
||
group: MailGroup;
|
||
showTo: boolean;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
currentMailID?: string;
|
||
onPick: (m: Mail) => void;
|
||
}) {
|
||
const g = group;
|
||
const time = new Date(g.latest.created_at).toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
// 列表行只显示「跟谁在通信」,**不带会话位**:分组头下面已经单独显示了
|
||
// 会话别名,再拼一遍会让长别名(实测 92 字节)把这一行挤没。
|
||
//
|
||
// 人还是 Agent 走显式布尔;workspace 从会话取(from_workspace 对 Agent
|
||
// 存的是 Agent 名而非路径)。
|
||
const isPeerHuman = showTo ? g.latest.to_human : g.latest.from_human;
|
||
const peer = participantAddress(
|
||
showTo ? g.latest.to_name : g.latest.from_name,
|
||
isPeerHuman,
|
||
isPeerHuman ? '' : g.latest.session_workspace || ''
|
||
);
|
||
|
||
// 组内含选中邮件时给个边框,否则展开一个组再滚下去会找不到自己在看哪封
|
||
const hasActive = currentMailID ? g.mails.some(m => m.mail_id === currentMailID) : false;
|
||
|
||
return (
|
||
<div
|
||
className={`rounded-lg border ${
|
||
hasActive ? 'border-blue-200 bg-blue-50/40' : 'border-transparent'
|
||
}`}
|
||
>
|
||
<button
|
||
onClick={onToggle}
|
||
className="w-full text-left px-3 py-2.5 rounded-lg hover:bg-gray-50"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<ChevronRightIcon
|
||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${
|
||
open ? 'rotate-90' : ''
|
||
}`}
|
||
/>
|
||
<span
|
||
className={`text-xs truncate flex-1 font-mono ${
|
||
g.unreadCount > 0 ? 'font-semibold text-gray-900' : 'text-gray-600'
|
||
}`}
|
||
>
|
||
{showTo ? '→ ' : ''}
|
||
{peer}
|
||
</span>
|
||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5 mt-0.5 pl-5">
|
||
{g.unreadCount > 0 && (
|
||
<span className="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">
|
||
{g.unreadCount}
|
||
</span>
|
||
)}
|
||
<span
|
||
className={`text-xs truncate ${
|
||
g.unreadCount > 0 ? 'font-medium text-gray-900' : 'text-gray-500'
|
||
}`}
|
||
>
|
||
{g.subject}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||
<span className="text-[10px] text-blue-500 font-mono truncate">
|
||
{g.alias ? `.${g.alias}` : '(未命名会话)'}
|
||
</span>
|
||
<span className="text-[10px] text-gray-400 shrink-0">{g.mails.length} 封</span>
|
||
</div>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="pl-5 pr-1 pb-1.5 space-y-0.5">
|
||
{g.mails.map(m => (
|
||
<MailItem
|
||
key={m.mail_id}
|
||
mail={m}
|
||
active={currentMailID === m.mail_id}
|
||
showTo={showTo}
|
||
onClick={() => onPick(m)}
|
||
compact
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MailItem({
|
||
mail,
|
||
active,
|
||
showTo,
|
||
onClick,
|
||
compact = false
|
||
}: {
|
||
mail: Mail;
|
||
active: boolean;
|
||
showTo: boolean;
|
||
onClick: () => void;
|
||
/** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */
|
||
compact?: boolean;
|
||
}) {
|
||
const isPermission = mail.mail_type === 'permission_request';
|
||
const isUnread = mail.status === 'unread';
|
||
const ccCount = mail.cc_list?.length ?? 0;
|
||
const attachCount = mail.attachments?.length ?? 0;
|
||
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
const isRowHuman = showTo ? mail.to_human : mail.from_human;
|
||
const peer = participantAddress(
|
||
showTo ? mail.to_name : mail.from_name,
|
||
isRowHuman,
|
||
isRowHuman ? '' : mail.session_workspace || ''
|
||
);
|
||
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
|
||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={`text-xs truncate flex-1 ${
|
||
compact ? '' : 'font-mono'
|
||
} ${isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'}`}
|
||
>
|
||
{compact ? (
|
||
<>
|
||
{isUnread && (
|
||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1.5 align-middle" />
|
||
)}
|
||
{mail.subject}
|
||
</>
|
||
) : (
|
||
<>
|
||
{showTo ? '→ ' : ''}
|
||
{peer}
|
||
</>
|
||
)}
|
||
</span>
|
||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||
</div>
|
||
|
||
{!compact && (
|
||
<div className="flex items-center gap-1.5 mt-0.5">
|
||
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
||
{isPermission && (
|
||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
|
||
<ShieldIcon className="w-2.5 h-2.5" />
|
||
权限
|
||
</span>
|
||
)}
|
||
<span
|
||
className={`text-xs truncate ${
|
||
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
|
||
}`}
|
||
>
|
||
{mail.subject}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
{/* 组内条目不重复显示别名(组头已有)。
|
||
发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */}
|
||
{compact && isPermission && (
|
||
<span
|
||
className={`inline-flex items-center gap-0.5 text-[10px] ${
|
||
mail.permission_result ? 'text-gray-400' : 'text-orange-600 font-medium'
|
||
}`}
|
||
>
|
||
<ShieldIcon className="w-2.5 h-2.5" />
|
||
{mail.permission_result || '待决策'}
|
||
</span>
|
||
)}
|
||
{!compact && mail.session_alias && (
|
||
<span className="min-w-0 flex-1 truncate text-[10px] text-blue-600 font-mono">.{mail.session_alias}</span>
|
||
)}
|
||
{ccCount > 0 && <span className="text-[10px] text-gray-400">抄送 {ccCount}</span>}
|
||
{attachCount > 0 && (
|
||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||
{attachCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|