chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View 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} />;
}