Files
MailUI4Agents/client/electron/src/components/AccountPage.tsx
JianFeeeee addde97600 feat(electron): 多账号第一纵切 —— 账号存储/选择器/聚合收件箱
按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与
写信账号切换留作下一轮)。

- `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并),
  16 条测试钉住每条判据(含反向对照)。
- 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+
  0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与
  http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。
- 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的
  令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。
- ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的
  服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。
- ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表
  少一整份邮件而界面看起来完全正常。
- `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它
  (`client.ts` 的 `const BASE` 快照已改成每次读)。
- UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页
  「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。
- 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、
  typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
2026-09-13 06:16:59 +08:00

257 lines
9.9 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 { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import { LockIcon, LogoutIcon } from './icons';
import KeyPanel from './KeyPanel';
import AccountList from './AccountList';
import ThemePicker from './ThemePicker';
import BackgroundPicker from './BackgroundPicker';
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
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-2xs 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-2xs 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-2xs 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-3xs 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>
{/* 多账号docs/MULTI-ACCOUNT-PLAN.md */}
<section className="border-t border-gray-200 pt-6">
<AccountList />
</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 space-y-6">
<ThemePicker />
<BackgroundPicker />
</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>
);
}