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>
);
}

View File

@ -3,6 +3,7 @@ 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';
@ -199,6 +200,11 @@ export default function AccountPage() {
</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

View File

@ -0,0 +1,138 @@
/**
* 账号选择器(顶栏下拉):像邮箱 app 的账号切换。
*
* 列表:`全部邮箱`(≥2 个可用账号时才出现——只有一个账号时它是噪声)、
* 每个账号(带未选中点/选中勾)、`管理账号…`。
*
* # 为什么"全部邮箱"在只有一个可用账号时不显示
*
* 聚合的语义是"合并多个账号的收件箱"。只有一个账号时它与该账号等价,
* 留着只会让人以为漏了谁。少于两个可用账号时选择器直接退化成静态标题。
*/
import { useEffect, useRef, useState } from 'react';
import { AGGREGATE_ID, isUsableAccount } from '../lib/accounts';
import { useAccountStore } from '../stores/accountStore';
import { useMailStore } from '../stores/mailStore';
import { useUIStore } from '../stores/uiStore';
import { ChevronRightIcon } from './icons';
export default function AccountSwitcher() {
const accounts = useAccountStore(s => s.accounts);
const activeId = useAccountStore(s => s.activeId);
const setActive = useAccountStore(s => s.setActive);
const fetchInbox = useMailStore(s => s.fetchInbox);
const setViewMode = useUIStore(s => s.setViewMode);
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const usable = accounts.filter(isUsableAccount);
const active = usable.find(a => a.id === activeId) ?? null;
const aggregateAvailable = usable.length >= 2;
// 点外面 / 按 Esc 关下拉:不关的话它会盖住列表且没有明显出口
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
// 没有账号(首次运行 / 全被删掉)时不渲染:那时界面该显示登录,
// 由 LoginPage 负责,这里画一个"未命名账号"的下拉只会添乱
if (usable.length === 0) return null;
const label = activeId === AGGREGATE_ID && aggregateAvailable ? '全部邮箱' : (active?.displayName ?? '全部邮箱');
const pick = async (id: string) => {
setOpen(false);
await setActive(id);
// 切账号后**必须重取**:收件箱是按账号返回的,不重取就会看到上一个账号的信。
// 这条在聚合⇄单账号之间同样成立(聚合要并发问多个账号)。
await fetchInbox('all');
};
const title = aggregateAvailable ? label : active?.displayName;
return (
<div className="relative ml-auto" ref={boxRef}>
<button
type="button"
data-testid="account-switcher"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => aggregateAvailable && setOpen(o => !o)}
className={`flex items-center gap-1 max-w-[150px] text-xs rounded-md px-2 py-1 border ${
aggregateAvailable
? 'border-gray-200 text-gray-600 hover:bg-gray-50 cursor-pointer'
: 'border-transparent text-gray-400 cursor-default'
}`}
>
<span className="truncate" title={title ?? ''}>
{title}
</span>
{aggregateAvailable && (
<ChevronRightIcon className={`w-3 h-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
)}
</button>
{open && (
<div
role="listbox"
data-testid="account-menu"
className="absolute right-0 top-full mt-1 z-20 w-56 bg-white border border-gray-200 rounded-lg shadow-lg py-1"
>
{aggregateAvailable && (
<button
type="button"
role="option"
aria-selected={activeId === AGGREGATE_ID}
data-testid="account-option-all"
onClick={() => pick(AGGREGATE_ID)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2"
>
<span className="flex-1">全部邮箱</span>
<span className="text-[11px] text-gray-400">{usable.length} 个账号</span>
</button>
)}
<div className="my-1 border-t border-gray-100" />
{usable.map(a => (
<button
key={a.id}
type="button"
role="option"
aria-selected={a.id === activeId}
data-testid={`account-option-${a.id}`}
onClick={() => pick(a.id)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50"
>
<span className="block truncate">{a.displayName}</span>
<span className="block text-[11px] text-gray-400 truncate">{a.gateway}</span>
</button>
))}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
data-testid="account-manage"
onClick={() => {
setOpen(false);
setViewMode('account');
}}
className="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
>
管理账号…
</button>
</div>
)}
</div>
);
}

View File

@ -5,6 +5,8 @@ import { useUIStore } from '../stores/uiStore';
import type { Mail } from '../types';
import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups';
import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons';
import AccountSwitcher from './AccountSwitcher';
import { isAggregate, useAccountStore } from '../stores/accountStore';
import { participantAddress } from '../lib/replyTarget';
export default function MailList() {
@ -20,6 +22,11 @@ export default function MailList() {
const fetchSent = useMailStore(s => s.fetchSent);
const clearSession = useSessionStore(s => s.clearSession);
// 聚合视图("全部邮箱"):列表要画账号徽标、并在某个账号取失败时提示
const activeId = useAccountStore(s => s.activeId);
const accountErrors = useMailStore(s => s.accountErrors);
const aggregate = isAggregate({ activeId });
// 哪些会话组被展开。默认全部折叠 —— 收件箱的问题正是「一次任务的几十封信
// 淹掉其他任务」,默认展开等于没分组
const [expanded, setExpanded] = useState<Set<string>>(new Set());
@ -66,6 +73,7 @@ export default function MailList() {
<div className="w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
<div className="px-4 py-3.5 border-b border-gray-200 flex items-center gap-1">
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<AccountSwitcher />
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
「有几件事」,后者只是流量 */}
<span className="ml-2 text-xs text-gray-500">
@ -75,6 +83,18 @@ export default function MailList() {
</span>
</div>
{/* 聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件,
而界面看起来完全正常 —— 这正是"聚合"最容易骗人的失败方式 */}
{aggregate && accountErrors.length > 0 && (
<div
data-testid="account-errors"
className="mx-2.5 mt-2 px-2.5 py-1.5 rounded-md bg-amber-50 border border-amber-200 text-[11px] text-amber-800"
>
有 {accountErrors.length} 个账号没取到:
{accountErrors.map(e => `${e.account}(${e.error})`).join(';')}
</div>
)}
<div className="flex-1 overflow-y-auto p-2.5 space-y-1">
{groups.map(g =>
isFlatGroup(g) ? (
@ -84,6 +104,7 @@ export default function MailList() {
active={currentMail?.mail_id === g.latest.mail_id}
showTo={isSent}
onClick={() => pick(g.latest)}
accountName={aggregate ? g.latest.account_name : undefined}
/>
) : (
<SessionGroup
@ -222,12 +243,15 @@ function MailItem({
active,
showTo,
onClick,
compact = false
compact = false,
accountName
}: {
mail: Mail;
active: boolean;
showTo: boolean;
onClick: () => void;
/** 聚合视图下的账号归属徽标(单账号视图传 undefined,不画) */
accountName?: string;
/** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */
compact?: boolean;
}) {
@ -276,6 +300,15 @@ function MailItem({
</>
)}
</span>
{accountName && (
<span
data-testid="account-badge"
className="text-[10px] leading-4 px-1.5 rounded-full bg-gray-100 text-gray-600 shrink-0 max-w-[80px] truncate"
title={`来自账号:${accountName}`}
>
{accountName}
</span>
)}
<span className="text-3xs text-gray-500 shrink-0">{time}</span>
</div>