Files
MailUI4Agents/client/electron/src/components/AdminUsersPage.tsx
JianFeeeee c19eea5e3c feat(webui): 真正动可见层的现代化 —— 字号、层次、间距、分隔线
# 起因:上一轮的「现代化」基本不算现代化

用户指出「我说的是 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。
2026-09-12 09:54:31 +08:00

452 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import type { AdminScopes, User } from '../types';
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
import KeyPanel from './KeyPanel';
import QuotaPanel from './QuotaPanel';
import ModelScopePanel from './ModelScopePanel';
type Tab = 'users' | 'keys' | 'quotas' | 'models';
export default function AdminUsersPage() {
const [tab, setTab] = useState<Tab>('users');
const [users, setUsers] = useState<User[]>([]);
const [scopes, setScopes] = useState<AdminScopes>({ agents: [], paths: [] });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
// Agent 密钥面板
const [keys, setKeys] = useState<api.AgentKey[]>([]);
const [keyBusy, setKeyBusy] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const [u, s] = await Promise.all([api.adminListUsers(), api.adminListScopes()]);
setUsers(u.users || []);
setScopes(s);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const loadKeys = useCallback(async () => {
try {
const r = await api.adminListAgentKeys();
setKeys(r.keys);
setKeyError(null);
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
}, []);
useEffect(() => {
if (tab === 'keys') loadKeys();
}, [tab, loadKeys]);
const createKey = async (payload: api.CreateKeyPayload) => {
setKeyBusy(true);
setKeyError(null);
try {
const r = await api.adminCreateAgentKey(payload);
// 登记客户端已有密钥时对方已经持有全文,无需再弹一次
setNewToken(payload.key_token ? null : r.key.key_token ?? null);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
} finally {
setKeyBusy(false);
}
};
const deleteKey = async (id: string) => {
setKeyError(null);
try {
await api.adminDeleteAgentKey(id);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
};
const bindKey = async (id: string, agentName: string) => {
setKeyError(null);
try {
await api.adminBindAgentKey(id, agentName);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
};
const flash = (msg: string) => {
setNotice(msg);
setTimeout(() => setNotice(null), 2500);
};
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-1 flex-wrap">
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
<UsersIcon className="w-4 h-4" />
<span className="text-xs text-gray-400">{users.length}</span>
</TabButton>
<TabButton active={tab === 'keys'} onClick={() => setTab('keys')}>
<KeyIcon className="w-4 h-4" />
Agent
</TabButton>
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
<BotIcon className="w-4 h-4" />
Agent
</TabButton>
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>
<CpuIcon className="w-4 h-4" />
</TabButton>
<div className="flex-1" />
{notice && <span className="text-xs text-green-600">{notice}</span>}
{tab === 'users' && (
<button onClick={() => setCreating(v => !v)} className="tap px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
{creating ? '收起' : '新建用户'}
</button>
)}
</div>
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
{tab === 'models' ? (
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<ModelScopeTab />
</div>
) : tab === 'quotas' ? (
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<QuotaPanel />
</div>
) : tab === 'keys' ? (
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<KeyPanel
variant="agent"
keys={keys}
loading={keyBusy}
error={keyError}
newToken={newToken}
onCreate={createKey}
onDelete={deleteKey}
onBind={bindKey}
onDismissToken={() => setNewToken(null)}
/>
</div>
) : (
<>
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-2">
{users.map(u => (
<UserCard key={u.user_id} user={u} scopes={scopes}
expanded={editing === u.user_id}
onToggle={() => setEditing(editing === u.user_id ? null : u.user_id)}
onSaved={flash} onReload={load} setError={setError} />
))}
{loading && users.length === 0 && <p className="text-xs text-gray-400 text-center py-6"></p>}
</div>
</>
)}
</div>
);
}
function TabButton({ active, onClick, children }: {
active: boolean; onClick: () => void; children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{children}
</button>
);
}
/* ── UserCard ── */
function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setError }: {
user: User; scopes: AdminScopes; expanded: boolean; onToggle: () => void;
onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
}) {
return (
<div className="rounded-lg border border-gray-200">
<div className="px-4 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={onToggle} className="tap flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
<span className="tap text-2xs text-gray-500">{user.display_name}</span>
<span className={`text-3xs px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
<span className={`text-3xs px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
{user.status === 'active' ? '启用' : '禁用'}
</span>
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && (
<span className="text-3xs text-gray-400"></span>
)}
<div className="flex-1" />
<span className="text-3xs text-gray-400">{user.last_login || '从未登录'}</span>
</div>
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
</div>
);
}
/* ── UserEditor ── */
function UserEditor({ user, scopes, onSaved, onReload, setError }: {
user: User; scopes: AdminScopes; onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
}) {
const [displayName, setDisplayName] = useState(user.display_name);
const [role, setRole] = useState<'admin' | 'user'>(user.role as 'admin' | 'user');
const [agents, setAgents] = useState<string[]>(user.allowed_agents);
const [paths, setPaths] = useState<string[]>(user.allowed_paths);
const [pw, setPw] = useState('');
const [busy, setBusy] = useState(false);
const toggleAgent = (a: string) => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a]);
const togglePath = (p: string) => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]);
const save = async () => {
setBusy(true);
try {
await api.adminUpdateUser(user.user_id, { display_name: displayName, role, allowed_agents: agents, allowed_paths: paths });
onSaved('用户已更新'); await onReload();
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
};
const disableUser = async () => {
try { await api.adminDisableUser(user.user_id); onSaved('用户已禁用'); await onReload(); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
};
const enableUser = async () => {
try { await api.adminUpdateUser(user.user_id, { status: 'active' }); onSaved('用户已启用'); await onReload(); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
};
const resetPassword = async () => {
if (pw.length < 8) return;
setBusy(true);
try { await api.adminResetPassword(user.user_id, pw); onSaved('密码已重置'); setPw(''); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
};
return (
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1"></label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
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" />
</div>
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1"></label>
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
<option value="user"></option>
<option value="admin"></option>
</select>
</div>
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1"></label>
{user.status === 'active' ? (
<button onClick={disableUser} className="px-3 py-1.5 text-xs rounded-md border border-red-300 text-red-600 hover:bg-red-50 w-full"></button>
) : (
<button onClick={enableUser} className="px-3 py-1.5 text-xs rounded-md border border-green-300 text-green-700 hover:bg-green-50 w-full"></button>
)}
</div>
</div>
{user.role !== 'admin' && (
<>
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1.5">
Agent {agents.length > 0 && <span className="text-gray-400">{agents.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{scopes.agents.map(a => (
<button key={a} onClick={() => toggleAgent(a)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
agents.includes(a) ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{a}</button>
))}
</div>
</div>
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1.5">
访 {paths.length > 0 && <span className="text-gray-400">{paths.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{scopes.paths.map(p => (
<button key={p} onClick={() => togglePath(p)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
paths.includes(p) ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{p}</button>
))}
</div>
</div>
</>
)}
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={save} disabled={busy} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
{busy ? '保存中' : '保存更改'}
</button>
<div className="flex items-center gap-1.5 ml-auto flex-wrap">
<LockIcon className="w-3 h-3 text-gray-400" />
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
className="tap inline-flex items-center gap-1 px-2 py-1 text-2xs rounded bg-chrome-700 text-white hover:bg-chrome-800 disabled:opacity-40">
<CheckIcon className="w-3 h-3" />
</button>
</div>
</div>
</div>
);
}
/* ── CreateUserForm ── */
function CreateUserForm({ scopes, onDone, onError }: {
scopes: AdminScopes; onDone: () => void; onError: (msg: string) => void;
}) {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<'admin' | 'user'>('user');
const [agents, setAgents] = useState<string[]>([]);
const [paths, setPaths] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const ok = username.trim().length >= 2 && password.length >= 8 && !busy;
const submit = async () => {
if (!ok) return;
setBusy(true);
try {
await api.adminCreateUser({
username: username.trim().toLowerCase(), password, display_name: displayName.trim(), role: role,
allowed_agents: role === 'admin' ? [] : agents, allowed_paths: role === 'admin' ? [] : paths,
});
setUsername(''); setDisplayName(''); setPassword(''); setRole('user'); setAgents([]); setPaths([]);
onDone();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
};
return (
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<Field label="用户名" hint="小写字母数字 . _ -">
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="alice" spellCheck={false}
className="w-full text-sm font-mono 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>
<Field label="显示名"><input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Alice"
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>
<Field label="初始密码" hint="至少 8 位"><input type="password" value={password} onChange={e => setPassword(e.target.value)}
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>
<Field label="角色">
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
<option value="user"></option>
<option value="admin"></option>
</select>
</Field>
</div>
{role !== 'admin' && (
<>
<ScopePick label="可调用 Agent" items={scopes.agents} selected={agents} onToggle={a => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a])} color="blue" />
<ScopePick label="可访问目录" items={scopes.paths} selected={paths} onToggle={p => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])} color="green" />
</>
)}
<div className="flex justify-end">
<button onClick={submit} disabled={!ok} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
{busy ? '创建中' : '创建'}
</button>
</div>
</div>
);
}
function ScopePick({ label, items, selected, onToggle, color }: {
label: string; items: string[]; selected: string[]; onToggle: (item: string) => void; color: 'blue' | 'green';
}) {
const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700';
return (
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1">
{label} <span className="font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{items.map(i => (
<button key={i} onClick={() => onToggle(i)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
selected.includes(i) ? active : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{i}</button>
))}
</div>
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<div className="flex items-baseline gap-1.5 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>
);
}
/**
* 模型范围页。
*
* Agent 列表复用 `/admin/quotas` —— 它返回的就是全部已注册 Agent。
* 另开一个「列出 Agent」接口只会多一条做同一件事的路径。
*/
function ModelScopeTab() {
const [agents, setAgents] = useState<api.AgentStats[]>([]);
const [err, setErr] = useState('');
useEffect(() => {
api
.adminListAgentStats()
.then(res => setAgents(res.quotas))
.catch(e => setErr(e instanceof Error ? e.message : String(e)));
}, []);
if (err) return <p className="text-xs text-red-600">{err}</p>;
return <ModelScopePanel agents={agents} />;
}