# 起因:上一轮的「现代化」基本不算现代化
用户指出「我说的是 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。
452 lines
19 KiB
TypeScript
452 lines
19 KiB
TypeScript
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} />;
|
||
}
|