# 起因:上一轮的「现代化」基本不算现代化
用户指出「我说的是 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。
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import Markdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import * as api from '../api/client';
|
||
import { useUIStore } from '../stores/uiStore';
|
||
import NarrowOnly from './NarrowOnly';
|
||
import { useMailStore } from '../stores/mailStore';
|
||
import { useSessionStore } from '../stores/sessionStore';
|
||
import { useContactStore } from '../stores/contactStore';
|
||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||
import AddressInput from './AddressInput';
|
||
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
||
import { ComposeIcon, ChevronLeftIcon } from './icons';
|
||
|
||
/** 完整的写邮件页面,占据右侧整个区域 */
|
||
export default function ComposePage() {
|
||
const prefill = useUIStore(s => s.composePrefill);
|
||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||
const fetchSent = useMailStore(s => s.fetchSent);
|
||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||
const isNarrow = useIsNarrow();
|
||
|
||
const [to, setTo] = useState(prefill?.to ?? '');
|
||
const [cc, setCc] = useState(prefill?.cc ?? '');
|
||
const [subject, setSubject] = useState('');
|
||
const [body, setBody] = useState('');
|
||
const [sessionAlias, setSessionAlias] = useState('');
|
||
// 本任务的往返预算。空 = 不限。
|
||
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
|
||
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||
const [maxRounds, setMaxRounds] = useState('');
|
||
// 权限档位(仅新建会话时生效):plan / workspace / full。
|
||
const [permissionMode, setPermissionMode] = useState('workspace');
|
||
// 收件 Agent 的默认预算;null = 还没查到(未注册的收件人也是 null)
|
||
const [agentDefault, setAgentDefault] = useState<number | null>(null);
|
||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||
const [preview, setPreview] = useState(false);
|
||
const [sending, setSending] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [okMsg, setOkMsg] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
setTo(prefill?.to ?? '');
|
||
setCc(prefill?.cc ?? '');
|
||
}, [prefill]);
|
||
|
||
// 三维地址的 name 位 = 收件 Agent 名
|
||
const toName = to.trim().split('@')[0].trim();
|
||
|
||
// 收件人变了就重查该 Agent 的默认预算。
|
||
// 只在新建会话时需要(续谈沿用会话已有预算),所以别的情况不打接口。
|
||
const isNewTarget = /\.new\s*$/.test(to.trim());
|
||
useEffect(() => {
|
||
if (!isNewTarget || toName === '') {
|
||
setAgentDefault(null);
|
||
return;
|
||
}
|
||
let alive = true;
|
||
api
|
||
.listAgents()
|
||
.then(r => {
|
||
if (!alive) return;
|
||
const hit = r.agents?.find(a => a.agent_name === toName);
|
||
setAgentDefault(hit?.default_rounds ?? null);
|
||
})
|
||
.catch(() => {
|
||
// 查不到就不显示提示,不该因此打断写信
|
||
if (alive) setAgentDefault(null);
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [toName, isNewTarget]);
|
||
|
||
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
|
||
// 命中已有会话或走默认会话时后端会忽略该字段。
|
||
const isNewSession = /\.new\s*$/.test(to.trim());
|
||
const aliasError =
|
||
isNewSession && sessionAlias.trim() !== '' && /[.\s/@]/.test(sessionAlias.trim())
|
||
? '别名不可含 . 空白 / 或 @'
|
||
: isNewSession && sessionAlias.trim() === 'new'
|
||
? '"new" 是寻址保留字'
|
||
: null;
|
||
|
||
// 输入框的 placeholder:人在派活时该看得到「不填会是多少」
|
||
const defaultRoundsHint =
|
||
agentDefault === null ? '默认' : agentDefault === 0 ? '不限' : `默认 ${agentDefault}`;
|
||
|
||
const roundsError =
|
||
maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim())
|
||
? '预算必须是非负整数(0 = 不限)'
|
||
: null;
|
||
|
||
const canSend =
|
||
roundsError === null &&
|
||
to.trim() !== '' &&
|
||
subject.trim() !== '' &&
|
||
body.trim() !== '' &&
|
||
aliasError === null &&
|
||
!sending;
|
||
|
||
const send = async () => {
|
||
if (!canSend) return;
|
||
setSending(true);
|
||
setError(null);
|
||
try {
|
||
const res = await api.sendMail(to.trim(), subject.trim(), body, {
|
||
cc: cc.trim(),
|
||
session_alias: isNewSession ? sessionAlias.trim() : '',
|
||
attachment_ids: attachments.map(a => a.id),
|
||
...(isNewSession && maxRounds.trim() !== ''
|
||
? { max_rounds: Number(maxRounds.trim()) }
|
||
: {}),
|
||
...(permissionMode ? { permission_mode: permissionMode } : {}),
|
||
});
|
||
const where = res.session_alias
|
||
? `会话别名 ${res.session_alias}`
|
||
: `会话 ${res.session_id.slice(0, 8)}`;
|
||
setOkMsg(
|
||
res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}`
|
||
);
|
||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||
setTimeout(() => {
|
||
setOkMsg(null);
|
||
cancelCompose();
|
||
}, 900);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white">
|
||
<div className="shrink-0 sticky top-0 z-10 lg:static px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
|
||
用 cancelCompose 而不是 showList:写信态本身要一起结束,
|
||
只滑走覆盖层的话下次进列表又会弹回来 */}
|
||
<NarrowOnly>
|
||
<button
|
||
onClick={cancelCompose}
|
||
className="tap -ml-1 inline-flex items-center gap-0.5 py-1 pr-1 text-gray-500 active:bg-gray-100 rounded"
|
||
aria-label="返回"
|
||
>
|
||
<ChevronLeftIcon className="w-4 h-4" />
|
||
</button>
|
||
</NarrowOnly>
|
||
<ComposeIcon className="w-4 h-4 text-blue-600 shrink-0" />
|
||
<h2 className="text-sm font-semibold text-gray-900">新建邮件</h2>
|
||
<div className="flex-1" />
|
||
<button
|
||
onClick={() => {
|
||
setTo('');
|
||
setCc('');
|
||
setSubject('');
|
||
setBody('');
|
||
setSessionAlias('');
|
||
setMaxRounds('');
|
||
setPermissionMode('');
|
||
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
|
||
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
|
||
setAttachments([]);
|
||
setError(null);
|
||
}}
|
||
className="tap text-xs text-gray-400 hover:text-gray-600"
|
||
>
|
||
清空
|
||
</button>
|
||
</div>
|
||
|
||
<div className="shrink-0 px-4 md:px-6 py-4 space-y-3 border-b border-gray-200">
|
||
<Field label="收件人" hint="name@path.session:省略=默认会话,new=新建,别名=已有会话">
|
||
<AddressInput
|
||
value={to}
|
||
onChange={setTo}
|
||
autoFocus={!isNarrow}
|
||
placeholder="deepseekharness@/program.upadtefeature"
|
||
/>
|
||
</Field>
|
||
|
||
{isNewSession && (
|
||
<Field label="会话别名" hint="可选;命名后可用 name@path.别名 续谈,全局唯一">
|
||
<input
|
||
value={sessionAlias}
|
||
onChange={e => setSessionAlias(e.target.value)}
|
||
placeholder="refactor-auth"
|
||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
{aliasError && <span className="text-3xs text-red-600">{aliasError}</span>}
|
||
</Field>
|
||
)}
|
||
|
||
<Field label="权限档位" hint={isNewSession ? "Agent 在这类任务里被允许动手的程度" : "改了即刻生效(该会话的档位会更新)"}>
|
||
<div className="flex items-center gap-2">
|
||
{[
|
||
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
|
||
{ value: 'workspace', label: '目录内', desc: '越界问人' },
|
||
{ value: 'full', label: '全权', desc: '自动放行' },
|
||
].map(o => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => setPermissionMode(o.value)}
|
||
title={o.desc}
|
||
className={`px-2.5 py-1.5 rounded-md border text-xs font-medium transition-colors ${
|
||
permissionMode === o.value
|
||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
|
||
{isNewSession && (
|
||
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
value={maxRounds}
|
||
onChange={e => setMaxRounds(e.target.value)}
|
||
inputMode="numeric"
|
||
placeholder={defaultRoundsHint}
|
||
className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
<span className="text-2xs text-gray-400">
|
||
个来回后 Agent 停止主动发信(自动转发的总结与权限询问不占预算)
|
||
</span>
|
||
</div>
|
||
{roundsError && <span className="text-3xs text-red-600">{roundsError}</span>}
|
||
</Field>
|
||
)}
|
||
|
||
<Field label="抄送" hint="多个地址用逗号分隔">
|
||
<AddressInput value={cc} onChange={setCc} allowMultiple placeholder="pi@root.new" />
|
||
</Field>
|
||
|
||
<Field label="主题">
|
||
<input
|
||
value={subject}
|
||
onChange={e => setSubject(e.target.value)}
|
||
placeholder="更新特性分支"
|
||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="shrink-0 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col">
|
||
<div className="shrink-0 flex items-center gap-2 mb-1.5">
|
||
<span className="text-2xs font-medium text-gray-500">正文(Markdown)</span>
|
||
<div className="flex-1" />
|
||
<Toggle active={!preview} onClick={() => setPreview(false)}>
|
||
编辑
|
||
</Toggle>
|
||
<Toggle active={preview} onClick={() => setPreview(true)}>
|
||
预览
|
||
</Toggle>
|
||
</div>
|
||
|
||
{preview ? (
|
||
<div className="markdown flex-1 min-h-[12rem] lg:min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4">
|
||
{body.trim() ? (
|
||
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
|
||
) : (
|
||
<p className="text-gray-400 text-sm">暂无内容</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<textarea
|
||
value={body}
|
||
onChange={e => setBody(e.target.value)}
|
||
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
|
||
className="flex-1 min-h-[12rem] lg:min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-y lg:resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 窄屏底部操作栏是 sticky,会在矮视口里向上吸附;预留一栏高度,
|
||
避免它覆盖附件按钮。桌面操作栏回到普通文档流,不需要这段缓冲。 */}
|
||
<div className="shrink-0 px-4 md:px-6 pb-20 lg:pb-3">
|
||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
|
||
</div>
|
||
|
||
<div className="shrink-0 sticky bottom-0 z-10 lg:static px-4 md:px-6 py-3 border-t border-gray-200 bg-white flex items-center gap-3 flex-wrap">
|
||
{error && <span className="min-w-0 text-xs text-red-600 break-words">{error}</span>}
|
||
{okMsg && <span className="min-w-0 text-xs text-green-700 break-words">{okMsg}</span>}
|
||
<div className="flex-1 min-w-2" />
|
||
<button
|
||
onClick={cancelCompose}
|
||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={send}
|
||
disabled={!canSend}
|
||
className="px-5 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{sending ? '发送中' : '发送'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
hint,
|
||
children
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<div className="flex items-baseline gap-2 mb-1">
|
||
<label className="text-2xs font-medium text-gray-500">{label}</label>
|
||
{hint && <span className="text-3xs text-gray-400">{hint}</span>}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Toggle({
|
||
active,
|
||
onClick,
|
||
children
|
||
}: {
|
||
active: boolean;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={`tap text-2xs px-2 py-0.5 rounded ${
|
||
active ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|