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,132 @@
/**
* 账号选择器的行为测试。
*
* 判据集中在三件会**静默出错**的事上:
* 1. 只有一个账号时不该出现「全部邮箱」(它是噪声,会让人以为漏了谁)
* 2. 从"全部邮箱"切到某个账号后**必须重取收件箱** —— 不重取就会继续显示
* 聚合结果,看起来像切换没生效
* 3. 没有账号时不该渲染这个控件(那时界面该由登录页负责)
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AccountSwitcher from '../../src/components/AccountSwitcher';
import { useAccountStore } from '../../src/stores/accountStore';
import { useMailStore } from '../../src/stores/mailStore';
import { useUIStore } from '../../src/stores/uiStore';
import * as api from '../../src/api/client';
const acct = (id: string, name: string) => ({
id,
displayName: name,
gateway: 'http://192.168.2.60:8180',
token: `tok-${id}`,
username: name
});
function seed(accounts: ReturnType<typeof acct>[], activeId: string) {
useAccountStore.setState({ accounts, activeId, loaded: true } as never);
}
describe('AccountSwitcher', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(api, 'getInbox').mockResolvedValue({ mails: [], total: 0 } as never);
useAccountStore.setState({ accounts: [], activeId: 'all', loaded: true } as never);
useMailStore.setState({ inbox: [], accountErrors: [] });
useUIStore.setState({ viewMode: 'inbox' });
});
it('没有账号时不渲染(那时该由登录页负责)', () => {
const { container } = render(<AccountSwitcher />);
expect(container.firstChild).toBeNull();
});
it('★ 只有一个账号时退化成静态标题:不出现「全部邮箱」', async () => {
seed([acct('a1', '工作邮箱')], 'a1');
render(<AccountSwitcher />);
const btn = screen.getByTestId('account-switcher');
expect(btn).toHaveTextContent('工作邮箱');
await userEvent.click(btn);
// 点不开:聚合没有意义(一个账号的"全部"就是它自己)
expect(screen.queryByTestId('account-menu')).toBeNull();
expect(screen.queryByTestId('account-option-all')).toBeNull();
});
it('两个账号时可以展开,菜单里有「全部邮箱」与各账号', async () => {
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
render(<AccountSwitcher />);
await userEvent.click(screen.getByTestId('account-switcher'));
expect(screen.getByTestId('account-menu')).toBeInTheDocument();
expect(screen.getByTestId('account-option-all')).toHaveTextContent('全部邮箱');
expect(screen.getByTestId('account-option-a1')).toHaveTextContent('工作邮箱');
expect(screen.getByTestId('account-option-a2')).toHaveTextContent('私人邮箱');
});
it('★ 切到某个账号后必须重取收件箱(否则会继续显示聚合结果)', async () => {
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'all');
render(<AccountSwitcher />);
await userEvent.click(screen.getByTestId('account-switcher'));
await userEvent.click(screen.getByTestId('account-option-a2'));
await waitFor(() => expect(useAccountStore.getState().activeId).toBe('a2'));
expect(api.getInbox).toHaveBeenCalled();
});
it('★ 切到「全部邮箱」也要重取(聚合要并发问多个账号)', async () => {
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
render(<AccountSwitcher />);
await userEvent.click(screen.getByTestId('account-switcher'));
await userEvent.click(screen.getByTestId('account-option-all'));
await waitFor(() => expect(useAccountStore.getState().activeId).toBe('all'));
// 两个可用账号 → 走聚合路径getInboxWithAuth 每个账号一次)
const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never);
await useMailStore.getState().fetchInbox('all');
expect(withAuth).toHaveBeenCalledTimes(2);
});
it('聚合模式下**只有一个可用账号**时不并发两个请求(第二条是多余失败面)', async () => {
useAccountStore.setState({
accounts: [{ ...acct('a1', '工作邮箱') }, { ...acct('a2', '坏账号'), token: '' }],
activeId: 'all',
loaded: true
} as never);
const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never);
await useMailStore.getState().fetchInbox('all');
expect(withAuth).not.toHaveBeenCalled();
expect(api.getInbox).toHaveBeenCalled();
});
it('★ 某个账号取失败必须在 store 里说出来,而不是静默少一份', async () => {
useAccountStore.setState({
accounts: [acct('a1', '工作邮箱'), acct('a2', '私人邮箱')],
activeId: 'all',
loaded: true
} as never);
vi.spyOn(api, 'getInboxWithAuth')
.mockResolvedValueOnce({ mails: [{ mail_id: 'm1' }], total: 1 } as never)
.mockRejectedValueOnce(new Error('HTTP 401密钥无效'));
await useMailStore.getState().fetchInbox('all');
expect(useMailStore.getState().inbox.map((m: any) => m.mail_id)).toEqual(['m1']);
expect(useMailStore.getState().accountErrors).toEqual([
{ account: '私人邮箱', error: 'HTTP 401密钥无效' }
]);
});
it('「管理账号…」跳到账号页', async () => {
seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1');
render(<AccountSwitcher />);
await userEvent.click(screen.getByTestId('account-switcher'));
await userEvent.click(screen.getByTestId('account-manage'));
expect(useUIStore.getState().viewMode).toBe('account');
});
});

View File

@ -1,7 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import LoginPage from '../../src/components/LoginPage';
import * as api from '../../src/api/client';