按 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`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
/**
|
||
* 账号选择器(顶栏下拉):像邮箱 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>
|
||
);
|
||
}
|