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,299 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import { useMailStore } from '../stores/mailStore';
import { participantAddress } from '../lib/replyTarget';
import type { ThreadNode } from '../types';
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
import BackButton from './BackButton';
import { useIsNarrow } from '../hooks/useIsNarrow';
/**
* 对话树视图(从线索根整树展开,分块加载)。
*
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
*
* 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」
* 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件
* 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的
* 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。
*
* 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。
*
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
*/
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
const [nodes, setNodes] = useState<ThreadNode[]>([]);
const [hidden, setHidden] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [nextOffset, setNextOffset] = useState(0);
const [err, setErr] = useState('');
const [initial, setInitial] = useState(true);
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const bottomSentinel = useRef<HTMLDivElement>(null);
const anchorRef = useRef<HTMLDivElement>(null);
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
const gen = useRef(0);
// loading 的同步副本。setState 是异步的,哨兵连续进入视口时
// 读 state 会看到旧的 false 而并发发两个请求。
const busy = useRef(false);
const sortNodes = (list: ThreadNode[]) =>
list
.slice()
.sort((a, b) =>
a.depth - b.depth || a.created_at.localeCompare(b.created_at)
);
const merge = useCallback((incoming: ThreadNode[]) => {
setNodes(prev => {
const seen = new Set(prev.map(n => n.mail_id));
return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]);
});
}, []);
// 首屏
useEffect(() => {
const myGen = ++gen.current;
setNodes([]);
setHidden(0);
setErr('');
setInitial(true);
busy.current = true;
api
.getMailThread(mailID, { offset: 0, limit: 60 })
.then(p => {
if (gen.current !== myGen) return;
setNodes(sortNodes(p.nodes));
setHidden(p.hidden);
setHasMore(p.has_more);
setNextOffset(p.next_offset);
})
.catch(e => {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (gen.current === myGen) {
setInitial(false);
busy.current = false;
}
});
}, [mailID]);
// 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后,
// 不滚过去的话用户点开一封邮件却停在整条线索的开头。
useEffect(() => {
if (initial || !anchorRef.current) return;
anchorRef.current.scrollIntoView({ block: 'center' });
}, [initial]);
const loadMore = useCallback(async () => {
if (busy.current || !hasMore) return;
const myGen = gen.current;
busy.current = true;
setLoading(true);
try {
const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 });
if (gen.current !== myGen) return;
merge(p.nodes);
setHidden(h => h + p.hidden);
setHasMore(p.has_more);
setNextOffset(p.next_offset);
} catch (e) {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
} finally {
if (gen.current === myGen) setLoading(false);
busy.current = false;
}
}, [mailID, merge, hasMore, nextOffset]);
// 底部哨兵进入视口就续取。rootMargin 提前 200px 触发,
// 让加载在用户滑到边界前完成。
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const obs = new IntersectionObserver(
entries => {
for (const e of entries) if (e.isIntersecting) loadMore();
},
{ root, rootMargin: '200px' }
);
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
return () => obs.disconnect();
}, [loadMore]);
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。
它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表,
关闭只收起树、留在这封邮件上。 */}
<BackButton />
<span className="text-sm font-semibold text-gray-900">对话树</span>
<span className="text-xs text-gray-400">
已加载 {nodes.length} 封
{hasMore && ',滑动加载更多'}
{hidden > 0 && `,${hidden} 封无权查看`}
</span>
<div className="flex-1" />
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
<button
onClick={onClose}
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<CloseIcon className="w-3.5 h-3.5" />
关闭
</button>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
{initial && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
加载中
</div>
)}
{err && <p className="text-xs text-red-600">{err}</p>}
{!initial && (
<>
<div className="space-y-1.5">
{nodes.map(n => (
<Node
key={n.mail_id}
node={n}
anchorID={mailID}
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
/>
))}
</div>
{hasMore && (
<button
onClick={loadMore}
className="tap w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
加载后续往来
</button>
)}
<div ref={bottomSentinel} className="h-px" />
</>
)}
</div>
</div>
);
}
function Node({
node,
anchorID,
anchorRef
}: {
node: ThreadNode;
anchorID: string;
anchorRef?: React.RefObject<HTMLDivElement>;
}) {
const openMailByID = useMailStore(s => s.openMailByID);
const narrow = useIsNarrow();
const isPermission = node.mail_type === 'permission_request';
const isAnchor = node.mail_id === anchorID;
const ccCount = node.cc_list?.length ?? 0;
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
const isForward = node.subject.startsWith('Fwd: ');
// 缩进:每级的像素数与上限都随屏宽变。
//
// 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉
// px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。
// 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。
const step = narrow ? 10 : 20;
const maxDepth = narrow ? 5 : 8;
const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step;
const time = new Date(node.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return (
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
{indent > 0 && (
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
)}
<button
onClick={() => openMailByID(node.mail_id)}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<div className="flex items-center gap-1.5">
{node.from_workspace ? (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
) : (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
)}
{/* 树节点一行里塞了 from → to、转发标记与时间,不带会话位:
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
人还是 Agent 走显式布尔;workspace 从会话取(from_workspace
对 Agent 存的是 Agent 名)。 */}
<span className="text-xs font-mono text-gray-700 truncate">
{participantAddress(
node.from_name,
node.from_human,
node.from_human ? '' : node.session_workspace || ''
)}
</span>
<span className="text-[10px] text-gray-400">→</span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" />
{isForward && (
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]">
转发
</span>
)}
{node.parent_hidden && (
<span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
title="上一封不在你的可见范围内"
>
上游不可见
</span>
)}
{isPermission && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
<ShieldIcon className="w-2.5 h-2.5" />
权限
</span>
)}
{node.attachment_count > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
<PaperclipIcon className="w-2.5 h-2.5" />
{node.attachment_count}
</span>
)}
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
{ccCount > 0 && (
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
抄送 {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}
</p>
)}
{node.body_preview && (
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
)}
{node.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
)}
</button>
</div>
);
}