# 起因:上一轮的「现代化」基本不算现代化
用户指出「我说的是 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。
172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
import { useRef, useState } from 'react';
|
||
import * as api from '../api/client';
|
||
import type { Attachment } from '../types';
|
||
import { PaperclipIcon, DownloadIcon, FileIcon, CloseIcon, SpinnerIcon } from './icons';
|
||
|
||
/** 已发出邮件的附件清单(只读,点击下载)。 */
|
||
export function AttachmentList({ items }: { items: Attachment[] }) {
|
||
if (!items || items.length === 0) return null;
|
||
|
||
return (
|
||
<div className="mt-4 border-t border-gray-100 pt-3">
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
|
||
<span className="text-2xs font-medium text-gray-500">
|
||
附件 {items.length}
|
||
</span>
|
||
</div>
|
||
<ul className="space-y-1">
|
||
{items.map(a => (
|
||
<li key={a.attachment_id}>
|
||
<a
|
||
href={api.attachmentURL(a.attachment_id)}
|
||
// download 让浏览器保存而非尝试渲染;服务端也已强制 octet-stream + attachment
|
||
download={a.filename}
|
||
className="group flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||
>
|
||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||
<span className="text-3xs text-gray-400 shrink-0">
|
||
{api.formatSize(a.size_bytes)}
|
||
</span>
|
||
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 待发送的附件:已上传到服务器、等着随邮件发出。 */
|
||
export interface PendingAttachment {
|
||
id: string;
|
||
filename: string;
|
||
size: number;
|
||
}
|
||
|
||
/**
|
||
* 写信时的附件选择器。
|
||
*
|
||
* 上传是独立一步:选中即上传,拿到 attachment_id 后暂存,发信时一并提交。
|
||
* 之所以不等到点「发送」再传:大文件上传要时间,让用户在写正文时就完成上传体验更好,
|
||
* 而且上传失败能立刻反馈而不是卡在发送那一刻。
|
||
*/
|
||
export function AttachmentPicker({
|
||
items,
|
||
onChange,
|
||
disabled
|
||
}: {
|
||
items: PendingAttachment[];
|
||
onChange: (next: PendingAttachment[]) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const pick = () => inputRef.current?.click();
|
||
|
||
const handleFiles = async (files: FileList | null) => {
|
||
if (!files || files.length === 0) return;
|
||
setError(null);
|
||
|
||
// 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制
|
||
const added: PendingAttachment[] = [];
|
||
for (const file of Array.from(files)) {
|
||
setUploading({ name: file.name, pct: 0 });
|
||
try {
|
||
const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct }));
|
||
added.push({
|
||
id: r.attachment.attachment_id,
|
||
filename: r.attachment.filename,
|
||
size: r.attachment.size_bytes
|
||
});
|
||
} catch (err) {
|
||
setError(`${file.name}:${err instanceof Error ? err.message : String(err)}`);
|
||
break; // 一个失败就停下,避免连续弹同类错误
|
||
}
|
||
}
|
||
setUploading(null);
|
||
if (added.length > 0) onChange([...items, ...added]);
|
||
|
||
// 清空 input,否则重复选同一个文件不会触发 change
|
||
if (inputRef.current) inputRef.current.value = '';
|
||
};
|
||
|
||
const remove = async (a: PendingAttachment) => {
|
||
// 从服务器删掉未挂载的附件,不然它会占着磁盘等 24 小时 GC
|
||
try {
|
||
await api.deleteAttachment(a.id);
|
||
} catch {
|
||
/* 删不掉也只是留给 GC,不该阻塞用户移除操作 */
|
||
}
|
||
onChange(items.filter(x => x.id !== a.id));
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={e => handleFiles(e.target.files)}
|
||
/>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||
<button
|
||
type="button"
|
||
onClick={pick}
|
||
disabled={disabled || uploading !== null}
|
||
className="tap shrink-0 inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||
>
|
||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||
添加附件
|
||
</button>
|
||
|
||
{uploading && (
|
||
<span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-2xs text-gray-500">
|
||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||
<span className="truncate">{uploading.name}</span>
|
||
<span className="shrink-0">{uploading.pct}%</span>
|
||
</span>
|
||
)}
|
||
|
||
{items.length > 0 && !uploading && (
|
||
<span className="text-2xs text-gray-500 min-w-0 break-words">
|
||
{items.length} 个附件 ·{' '}
|
||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{error && <div className="text-2xs text-red-600 break-words">{error}</div>}
|
||
|
||
{items.length > 0 && (
|
||
<ul className="space-y-1">
|
||
{items.map(a => (
|
||
<li
|
||
key={a.id}
|
||
className="flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 bg-gray-50"
|
||
>
|
||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||
<span className="text-3xs text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(a)}
|
||
disabled={disabled}
|
||
title="移除"
|
||
className="tap shrink-0 inline-flex items-center justify-center text-gray-500 hover:text-red-600 disabled:opacity-40"
|
||
>
|
||
<CloseIcon className="w-3.5 h-3.5" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|