Files
MailUI4Agents/client/electron/src/components/AccountList.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

190 lines
7.5 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.

/**
* 多账号管理(账号页里的一段):列出已添加账号、添加新账号、删除。
*
* # 添加时**真的去验证**连通性
*
* 粘贴一个错的密钥不会报错——它只会表现为"这个账号的收件箱一直是空的"。
* 所以添加流程必须调一次 `GET /auth/me`
* 成功 → 显示用户名(用户能确认"这是我要的那个账号"
* 401 → 明确说"密钥无效",不写进账号列表
* 网络错 → 说清是哪一种(连不上 / 超时),因为这两种的修法不同
*
* 用「显式认证」发这个请求(`fetchWithAuth`),不碰当前账号的认证单例 ——
* 否则验证一个坏账号的过程会把当前账号的令牌换掉。
*/
import { useState } from 'react';
import { fetchWithAuth } from '../api/config';
import { deriveDisplayName, normalizeGateway } from '../lib/accounts';
import { activeAccount, useAccountStore } from '../stores/accountStore';
export default function AccountList() {
const accounts = useAccountStore(s => s.accounts);
const activeId = useAccountStore(s => s.activeId);
const ephemeral = useAccountStore(s => s.ephemeralStorage);
const storageFile = useAccountStore(s => s.storageFile);
const addAccount = useAccountStore(s => s.add);
const removeAccount = useAccountStore(s => s.remove);
const setActive = useAccountStore(s => s.setActive);
const current = useAccountStore(activeAccount);
const [open, setOpen] = useState(false);
const [gateway, setGateway] = useState('');
const [token, setToken] = useState('');
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const submit = async () => {
setMsg(null);
const g = normalizeGateway(gateway);
const t = token.trim();
if (!g) return setMsg({ kind: 'err', text: '请填写 Gateway 地址' });
if (!t) return setMsg({ kind: 'err', text: '请填写用户密钥' });
setBusy(true);
try {
// 先验证,再入库 —— 顺序反过来就会出现"列表里有个永远空的账号"
const res = await fetchWithAuth({ base: `${g}/api/v1`, token: t }, '/auth/me');
const body = await res.json().catch(() => ({}));
if (res.status === 401 || res.status === 403) {
setBusy(false);
return setMsg({ kind: 'err', text: '密钥无效(服务端返回 401——请确认用的是用户密钥' });
}
if (!res.ok) {
setBusy(false);
return setMsg({ kind: 'err', text: `服务端返回 ${res.status}${body?.error ? `${body.error}` : ''}` });
}
const username = body?.user?.username || body?.username || '';
const r = await addAccount({
gateway: g,
token: t,
username,
displayName: name.trim() || deriveDisplayName(g, username)
});
setBusy(false);
if (!r.ok) return setMsg({ kind: 'err', text: r.error || '添加失败' });
setMsg({ kind: 'ok', text: `已添加${username ? `${username}` : ''}` });
setGateway('');
setToken('');
setName('');
setOpen(false);
} catch (e) {
setBusy(false);
// 连不上 / 超时 / CORS 都要能分辨:这里至少把原始错误原样说出来
setMsg({ kind: 'err', text: `连不上:${(e as Error)?.message || e}` });
}
};
return (
<div data-testid="account-list">
<div className="flex items-center gap-2 mb-3">
<h3 className="text-xs font-medium text-gray-500"></h3>
<button
type="button"
data-testid="account-add-toggle"
onClick={() => setOpen(o => !o)}
className="ml-auto text-xs text-blue-600 hover:underline"
>
{open ? '取消' : '+ 添加账号'}
</button>
</div>
{/* 存储位置如实说:网页端退到 localStorage那不是安全存储 */}
<p className="text-[11px] text-gray-400 mb-3">
{ephemeral
? '账号保存在浏览器 localStorage当前不在桌面端这不是加密存储'
: `账号保存在本机文件:${storageFile || '(未知路径)'}(权限 600`}
</p>
<ul className="space-y-1.5">
{accounts.map(a => {
const usable = Boolean(a.token && a.gateway);
return (
<li
key={a.id}
data-testid={`account-row-${a.id}`}
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200"
>
<span className="min-w-0 flex-1">
<span className="block text-sm text-gray-800 truncate">{a.displayName}</span>
<span className="block text-[11px] text-gray-400 truncate">
{a.gateway}
{a.username ? ` · ${a.username}` : ''}
</span>
</span>
{!usable && <span className="text-[11px] text-amber-600"></span>}
{a.id === current?.id ? (
<span className="text-[11px] text-blue-600"></span>
) : a.id !== activeId ? (
<button
type="button"
data-testid={`account-use-${a.id}`}
onClick={() => setActive(a.id)}
className="text-[11px] text-gray-600 hover:underline"
>
</button>
) : null}
<button
type="button"
data-testid={`account-del-${a.id}`}
onClick={() => removeAccount(a.id)}
className="text-[11px] text-red-600 hover:underline"
>
</button>
</li>
);
})}
{accounts.length === 0 && <li className="text-xs text-gray-400"></li>}
</ul>
{open && (
<div className="mt-3 space-y-2 p-3 rounded-md bg-gray-50 border border-gray-200">
<input
data-testid="account-gateway"
value={gateway}
onChange={e => setGateway(e.target.value)}
placeholder="Gateway 地址,如 http://192.168.2.60:8180"
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300"
/>
<input
data-testid="account-token"
value={token}
onChange={e => setToken(e.target.value)}
placeholder="用户密钥user key64 位十六进制)"
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300 font-mono"
/>
<input
data-testid="account-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="显示名(可留空,默认用 用户名@主机)"
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300"
/>
<button
type="button"
data-testid="account-add-submit"
disabled={busy}
onClick={submit}
className="tap px-3 py-1.5 text-sm rounded-md bg-blue-600 text-white disabled:opacity-50"
>
{busy ? '验证中…' : '验证并添加'}
</button>
<p className="text-[11px] text-gray-500"> /auth/me </p>
</div>
)}
{msg && (
<p
data-testid="account-msg"
className={`mt-2 text-xs ${msg.kind === 'ok' ? 'text-green-600' : 'text-red-600'}`}
>
{msg.text}
</p>
)}
</div>
);
}