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`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
This commit is contained in:
2026-09-13 06:16:59 +08:00
parent c7cb88d9aa
commit addde97600
18 changed files with 1671 additions and 22 deletions

View File

@ -0,0 +1,189 @@
/**
* 多账号管理(账号页里的一段):列出已添加账号、添加新账号、删除。
*
* # 添加时**真的去验证**连通性
*
* 粘贴一个错的密钥不会报错——它只会表现为"这个账号的收件箱一直是空的"。
* 所以添加流程必须调一次 `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 key,64 位十六进制)"
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>
);
}