# 起因:上一轮的「现代化」基本不算现代化
用户指出「我说的是 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。
277 lines
9.6 KiB
TypeScript
277 lines
9.6 KiB
TypeScript
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-2xs 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-2xs 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-3xs text-gray-500 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-3xs 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-2xs 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-2xs 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-3xs text-gray-500 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-3xs 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-2xs text-blue-600 font-mono truncate">
|
||
{contact.session_alias || '(未命名会话)'}
|
||
</span>
|
||
</div>
|
||
<p className="text-3xs text-gray-500 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-3xs 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-3xs text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
|
||
>
|
||
<ArchiveIcon className="w-3 h-3" />
|
||
归档
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|