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,248 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import { LockIcon, LogoutIcon } from './icons';
import KeyPanel from './KeyPanel';
import ThemePicker from './ThemePicker';
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
export default function AccountPage() {
const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout);
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// 密钥面板状态
const [keys, setKeys] = useState<api.UserKey[]>([]);
const [keyBusy, setKeyBusy] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<string | null>(null);
const loadKeys = useCallback(async () => {
try {
const r = await api.listMyKeys();
setKeys(r.keys);
setKeyError(null);
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
}, []);
useEffect(() => {
loadKeys();
}, [loadKeys]);
const createKey = async (payload: api.CreateKeyPayload) => {
setKeyBusy(true);
setKeyError(null);
try {
const r = await api.createMyKey(payload);
// 全文只在创建响应里出现一次,必须当场展示
setNewToken(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.deleteMyKey(id);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
};
const mismatch = confirmPw !== '' && newPw !== confirmPw;
const canSubmit = oldPw.length > 0 && newPw.length >= 8 && !mismatch && !busy;
const changePw = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setBusy(true);
setError(null);
setMsg(null);
try {
await api.changePassword(oldPw, newPw);
setMsg('密码已修改,请重新登录');
setOldPw('');
setNewPw('');
setConfirmPw('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (!user) return null;
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="shrink-0 px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
</div>
{/* 滚动容器。
缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)
比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,
没有任何办法滚到。实测 390px 下内容需 860px、容器只有 795px;
1280x800 的桌面上同样看不到最后的「退出登录」。 */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
{/* 基本信息 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3">基本资料</h3>
<dl className="text-sm space-y-2">
<Row label="用户名" value={user.username} mono />
<Row label="显示名" value={user.display_name} />
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
<Row label="创建时间" value={user.created_at || '-'} />
<Row label="最后登录" value={user.last_login || '从未登录'} />
</dl>
</section>
{/* 权限边界 */}
{user.role !== 'admin' && (
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3">权限范围</h3>
<dl className="text-sm space-y-2">
<Row
label="可调用 Agent"
value={
user.allowed_agents.length === 0
? '不限(全部可用)'
: user.allowed_agents.join(', ')
}
/>
<Row
label="可访问目录"
value={
user.allowed_paths.length === 0
? '不限(全部可用)'
: user.allowed_paths.join(', ')
}
/>
</dl>
</section>
)}
{/* 修改密码 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
<LockIcon className="w-3.5 h-3.5" />
修改密码
</h3>
<form onSubmit={changePw} className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">当前密码</label>
<input
type="password"
value={oldPw}
onChange={e => setOldPw(e.target.value)}
autoComplete="current-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 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">新密码(至少 8 位)</label>
<input
type="password"
value={newPw}
onChange={e => setNewPw(e.target.value)}
autoComplete="new-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 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>
<input
type="password"
value={confirmPw}
onChange={e => setConfirmPw(e.target.value)}
autoComplete="new-password"
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
}`}
/>
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次密码不一致</p>}
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
{error}
</p>
)}
{msg && (
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
{msg}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{busy ? '保存中' : '保存'}
</button>
</form>
</section>
{/* 客户端连接密钥 */}
<section className="border-t border-gray-200 pt-6">
<KeyPanel
variant="user"
keys={keys}
loading={keyBusy}
error={keyError}
newToken={newToken}
onCreate={createKey}
onDelete={deleteKey}
onDismissToken={() => setNewToken(null)}
/>
</section>
{/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好,
与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */}
<section className="border-t border-gray-200 pt-6">
<ThemePicker />
</section>
{/* 退出登录。
放在这里而不是导航里:它是一个低频且不可逆的动作,
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:
抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */}
<section className="border-t border-gray-200 pt-6">
<h3 className="text-xs font-medium text-gray-500 mb-3">登录状态</h3>
<button
onClick={logout}
className="tap inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
>
<LogoutIcon className="w-4 h-4" />
退出登录
</button>
</section>
</div>
</div>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline gap-3">
<dt className="w-20 shrink-0 text-xs text-gray-400">{label}</dt>
<dd className={`text-sm text-gray-900 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
</div>
);
}