chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
451
client/electron/src/components/AdminUsersPage.tsx
Normal file
451
client/electron/src/components/AdminUsersPage.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
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-[11px] text-gray-500">{user.display_name}</span>
|
||||
<span className={`text-[10px] 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-[10px] 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-[10px] text-gray-400">受限</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] 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-[11px] 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-[11px] 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-[11px] 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-[11px] 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-[11px] 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-[11px] 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-[11px] 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-[11px] font-medium text-gray-500">{label}</label>
|
||||
{hint && <span className="text-[10px] 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user