chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,327 @@
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>
);
}