# 起因:上一轮的「现代化」基本不算现代化
用户指出「我说的是 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。
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import * as api from '../api/client';
|
||
import { BotIcon, CheckIcon, ArchiveIcon } from './icons';
|
||
|
||
/**
|
||
* Agent 管理面板(管理员):默认预算 + 停用/恢复。
|
||
*
|
||
* 预算决定「派给某个 Agent 的新任务默认多少个来回」。
|
||
* 停用/恢复控制 Agent 的准入:停用后密钥撤销、注册被拒,邮件与会话保留。
|
||
*/
|
||
export default function QuotaPanel() {
|
||
const [stats, setStats] = useState<api.AgentStats[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||
const [confirming, setConfirming] = useState<string | null>(null);
|
||
const [confirmAction, setConfirmAction] = useState<'toggle' | 'delete' | null>(null);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const r = await api.adminListAgentStats();
|
||
setStats(r.quotas);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
const apply = async (name: string, defaultRounds: number) => {
|
||
setBusy(name);
|
||
setError(null);
|
||
try {
|
||
await api.adminSetDefaultRounds(name, defaultRounds);
|
||
await load();
|
||
setDrafts(d => {
|
||
const next = { ...d };
|
||
delete next[name];
|
||
return next;
|
||
});
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const deleteAgent = async (name: string) => {
|
||
setBusy(name);
|
||
setError(null);
|
||
setNotice(null);
|
||
try {
|
||
const result = await api.adminDeleteAgent(name);
|
||
await load();
|
||
setConfirming(null);
|
||
setConfirmAction(null);
|
||
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
|
||
? `(已撤销 ${result.keys_revoked} 把密钥)`
|
||
: '';
|
||
setNotice(`${name} 已删除${revoked}`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const toggleStatus = async (name: string, currentDisabled: boolean) => {
|
||
setBusy(name);
|
||
setError(null);
|
||
setNotice(null);
|
||
try {
|
||
const result = await api.adminSetAgentStatus(name, !currentDisabled);
|
||
await load();
|
||
setConfirming(null);
|
||
// 撤销了几把密钥是停用操作里最有信息量的部分 —— 恢复后要重新签发几把,
|
||
// 只说「已停用」的话用户不知道还有这一步。
|
||
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
|
||
? `(已撤销 ${result.keys_revoked} 把密钥)`
|
||
: '';
|
||
// 恢复路径必须把「密钥不会自动回来」说出口。否则用户点完恢复就算完事,
|
||
// 而插件拿着已撤销的密钥无限重试并被 401 —— 本会话就踩过:
|
||
// opencode 被停用后拿旧密钥重试了 18 小时,gateway 日志里 2690 次 401。
|
||
setNotice(result.disabled
|
||
? `${name} 已停用${revoked}`
|
||
: `${name} 已恢复为离线。密钥不会自动回来 —— 请到「密钥」面板重新签发一把并写进插件配置,否则它会一直被拒(401)。`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const statusBadge = (s: api.AgentStats) => {
|
||
const st = s.status ?? 'offline';
|
||
const cls = st === 'online'
|
||
? 'bg-green-50 text-green-700'
|
||
: st === 'disabled'
|
||
? 'bg-red-50 text-red-600'
|
||
: 'bg-gray-50 text-gray-500';
|
||
const label = st === 'online' ? '在线' : st === 'disabled' ? '已停用' : '离线';
|
||
return <span className={`text-3xs px-1.5 py-0.5 rounded ${cls}`}>{label}</span>;
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<BotIcon className="w-4 h-4 text-gray-500" />
|
||
<h3 className="text-sm font-semibold text-gray-900">Agent 管理</h3>
|
||
<span className="text-xs text-gray-400">{stats.length}</span>
|
||
</div>
|
||
|
||
<p className="text-2xs text-gray-500">
|
||
<strong className="font-medium text-gray-600">默认预算</strong>:派给某个 Agent 的新任务默认多少个来回(填 0 = 不限)。
|
||
这只是默认值 —— 写信时可以单独指定,之后在对话页里还能随时调整。
|
||
插件自动转发的最终总结与权限询问不占用预算。
|
||
<br />
|
||
<strong className="font-medium text-gray-600">停用</strong>:撤销该 Agent 的全部密钥、
|
||
从地址补全与联系人里隐藏,拒绝它重新注册,并让别人发信给它时收到 409。
|
||
<span className="text-gray-400">邮件、会话、模型范围全部保留,随时可恢复 —— 但密钥不会自动回来,恢复后需重新签发。</span>
|
||
<br />
|
||
<strong className="font-medium text-gray-600">删除</strong>:在停用的基础上清掉运行态(日历事件置 cancelled)。
|
||
<span className="text-gray-400">邮件与会话保留(历史是审计凭据),此名字今后不可再注册。</span>
|
||
</p>
|
||
|
||
{error && <div className="text-2xs text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>}
|
||
{notice && <div className="text-2xs text-green-700 bg-green-50 rounded px-2 py-1.5">{notice}</div>}
|
||
|
||
{stats.length === 0 ? (
|
||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||
) : (
|
||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||
{stats.map(s => {
|
||
const draft = drafts[s.agent_name] ?? String(s.default_rounds);
|
||
const dirty = draft !== String(s.default_rounds);
|
||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||
const isDisabled = s.status === 'disabled';
|
||
const isConfirming = confirming === s.agent_name;
|
||
|
||
return (
|
||
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
|
||
{s.agent_name}
|
||
</span>
|
||
|
||
{statusBadge(s)}
|
||
|
||
<div className="min-w-0 flex-1 text-2xs text-gray-500">
|
||
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
|
||
<span className="text-gray-400">
|
||
{' · '}进行中 {s.active_sessions} 个任务 · 累计发信 {s.sent_total}
|
||
</span>
|
||
</div>
|
||
|
||
<input
|
||
value={draft}
|
||
onChange={e => setDrafts(d => ({ ...d, [s.agent_name]: e.target.value }))}
|
||
inputMode="numeric"
|
||
title="新任务默认往返数;0 = 不限"
|
||
className={`w-16 text-xs border rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||
invalid ? 'border-red-300' : 'border-gray-300'
|
||
}`}
|
||
/>
|
||
<button
|
||
onClick={() => apply(s.agent_name, Number(draft.trim() || '0'))}
|
||
disabled={!dirty || invalid || busy === s.agent_name}
|
||
title="保存默认预算"
|
||
className="tap shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||
>
|
||
<CheckIcon className="w-4 h-4" />
|
||
</button>
|
||
|
||
{/* 停用/恢复/删除 按钮 */}
|
||
{isConfirming ? (
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-3xs text-gray-500">确认{confirmAction === 'delete' ? '删除' : ''}?</span>
|
||
<button
|
||
onClick={() => confirmAction === 'delete'
|
||
? deleteAgent(s.agent_name)
|
||
: toggleStatus(s.agent_name, isDisabled)}
|
||
disabled={busy === s.agent_name}
|
||
className="tap text-3xs px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
|
||
>
|
||
{confirmAction === 'delete' ? '删除' : (isDisabled ? '恢复' : '停用')}
|
||
</button>
|
||
<button
|
||
onClick={() => { setConfirming(null); setConfirmAction(null); }}
|
||
className="tap text-3xs text-gray-400 hover:text-gray-600"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => { setConfirming(s.agent_name); setConfirmAction('toggle'); }}
|
||
disabled={busy === s.agent_name}
|
||
title={isDisabled
|
||
? '恢复此 Agent(恢复为离线;停用时撤销的密钥不会自动回来,必须重新签发)'
|
||
: '停用此 Agent(撤销全部密钥并从补全里隐藏;邮件与会话保留,可恢复)'}
|
||
className={`tap shrink-0 inline-flex items-center gap-1 text-2xs px-1.5 py-0.5 rounded border ${
|
||
isDisabled
|
||
? 'border-green-200 text-green-700 hover:bg-green-50'
|
||
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
|
||
} disabled:opacity-30`}
|
||
>
|
||
<ArchiveIcon className="w-3 h-3" />
|
||
{isDisabled ? '恢复' : '停用'}
|
||
</button>
|
||
<button
|
||
onClick={() => { setConfirming(s.agent_name); setConfirmAction('delete'); }}
|
||
disabled={busy === s.agent_name}
|
||
title="彻底删除此 Agent(清除密钥与运行态;邮件保留但此名今后不可再用)"
|
||
className="tap shrink-0 text-3xs px-1.5 py-0.5 rounded border border-red-200 text-red-400 hover:text-red-600 hover:border-red-300 disabled:opacity-30"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|