# 起因:上一轮的「现代化」基本不算现代化
用户指出「我说的是 webui 现代化」。回看上一轮,我交付的其实是**底层改进**:
圆角加大一档、自定义滚动条、焦点环、过渡、reduce-motion、令牌与可访问性。
这些都对,但**可见变化几乎只有圆角** —— 界面看起来还是老样子。
实测数据确认了「老」在哪:
text-xs(12px) 172 处 ← 被当正文用
text-[10px] 84 处
text-[11px] 68 处
text-[9px] 19 处 ← 现代显示器上基本读不了
text-sm(14px) 69 处
text-base(16px) 4 处
57 处 border-b + 21 处 border-r,其中 67 条是 border-gray-200 的硬灰线
阴影:全站共 10 处,且全是 Tailwind 系统默认档;
index.css 里 --shadow-1/2/3 三个语义令牌**定义了但零处使用**
所以真正的病因是三条:**字太小、层次为零、硬线切分**。
# 改动
## 1. 字号体系抬一档(tailwind.config.js)
不用 Tailwind 默认档,重定为:
3xs 11px(角标下限,取代 9/10px 魔法数字)
2xs 12px(元信息,取代 11px)
xs 13px(次要正文,原 12px —— 拿它当正文的地方自动变舒适)
sm 14px(正文)
base 15px
并把 171 处裸 px 类名(text-[9px]/[10px]/[11px])统一换成令牌 ——
顺带消除魔法数字。行高一起给:小档位 1.35/1.45,正文 1.55,
只放大字号不放行高会把密排列表顶得很难看。
## 2. 把层次接出来(原本是死代码)
tailwind.config.js 新增 boxShadow 映射 `--shadow-1/2/3` + 新增
`--shadow-panel`(横向偏移 + 大扩散,竖向几乎不偏移,否则全高面板像浮在半空)。
用于:列表面板(lg:shadow-panel,**同时去掉 border-r 硬线**)、
登录/初始化卡片(shadow-sm → shadow-2 + 去硬边框)、
地址自动补全下拉(shadow-lg → shadow-2)、窄屏滑入详情面板
(shadow-2xl → shadow-3 + 去 border-l)、主题分段控件的选中滑块。
深色下层次比浅色更难感知,所以 --shadow-panel 在深色里更实一些;
深色里靠边框分组几乎看不见,层次是**唯一**有效的分组手段。
## 3. 分隔线软化(改令牌而不是改 67 处类名)
`--c-gray-200` 浅色 229 231 235 → 234 236 241,深色 44 49 59 → 39 43 52。
改在令牌上,67 条边框 + 8 处底色一次性生效且不会漏。
**刻意没有一起调 gray-300**:它同时是滚动条滑块色,调淡会让滑块更难看见。
## 4. 配比放宽(列表行的呼吸感)
MailList:行内距 px-3 py-2.5 → px-3.5 py-3,列表 gap space-y-0.5 → space-y-1,
表头 py-3 → py-3.5。未读主题字重 medium → semibold,已读 gray-500 → gray-600。
## 5. 量出来的两个真实对比度缺陷(不是估算)
新增 `test/manual/modernization-verify.mjs`,用真实渲染做四条判据。
它量出浅色下两个 WCAG AA 不达标(阈值 4.5:1):
- 会话别名 `text-blue-500` 白底 3.68:1(别名在 mail list / thread / mailview
共 4 处,都是 11px 小字)→ 改 blue-600/700,达 5.17:1
- 时间戳 `text-gray-400` 压在选中行淡蓝底 `bg-blue-50` 上 4.44:1
第二个的**根因是调色板缺一档**:浅色下 `--c-gray-400` 与 `--c-gray-500`
完全相同(都是 107 114 128),于是「比次要文字再深一档的中间色」根本不存在,
时间戳无处可退。拉开 gray-500 → 90 98 112(5.65:1),并把 5 个列表组件的
行内元信息(19 处)从 gray-400 提到 gray-500。
# 验证
- typecheck 干净
- 前端全量 `npm test` EXIT=0(markdown-xss / narrow-layout / theme 30 /
background 15 / vitest 216)
- **真实渲染** `modernization-verify.mjs`:浅色 8/8、深色 8/8,判据含
最小字号 ≥ 11px(改造前 9px)、邮件正文 ≥ 14px、列表面板真有 box-shadow、
gray-200 是软化值、40 处正文对比度全部达标
# 我自己的三处错(都被这次的度量拦下)
1. **判据量错对象**:第一版拿「收件箱列表」要求 40% 元素 ≥13px,量出 39.7%
判失败 —— 而收件箱本质是元信息密集区,发件人/时间/别名本来就该小。
改成量真正该达标的**邮件正文**(≥14px)。
2. **探针忽略 alpha**:`parseRgb` 把 `rgba(239,246,255,0.4)` 的 alpha 丢掉当实色,
于是把淡蓝底当纯蓝算出 4.44:1 的假缺陷。改为按画家算法合成整条背景链。
3. **config 注释换算写错**:3xs 注释写 10px,0.6875rem 其实是 11px。
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
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-3xs text-gray-500">→</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-3xs">
|
||
转发
|
||
</span>
|
||
)}
|
||
{node.parent_hidden && (
|
||
<span
|
||
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-3xs"
|
||
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-3xs">
|
||
<ShieldIcon className="w-2.5 h-2.5" />
|
||
权限
|
||
</span>
|
||
)}
|
||
{node.attachment_count > 0 && (
|
||
<span className="inline-flex items-center gap-0.5 text-3xs text-gray-500">
|
||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||
{node.attachment_count}
|
||
</span>
|
||
)}
|
||
<span className="text-3xs text-gray-500 shrink-0">{time}</span>
|
||
</div>
|
||
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
|
||
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
|
||
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
|
||
{ccCount > 0 && (
|
||
<p className="text-3xs text-gray-500 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-2xs text-gray-500 mt-0.5 line-clamp-2">{node.body_preview}</p>
|
||
)}
|
||
{node.session_alias && (
|
||
<span className="text-3xs text-blue-600 font-mono">.{node.session_alias}</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|