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

139 lines
5.3 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.

/**
* 账号选择器(顶栏下拉):像邮箱 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>
);
}