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:
@ -32,7 +32,17 @@ function resolveBase(): string {
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const API_BASE = resolveBase();
|
||||
/**
|
||||
* 当前生效的 API 基地址。
|
||||
*
|
||||
* **是 `let` 而不是 `const`**:多账号下每个账号自带 gateway,"当前账号"换了
|
||||
* 基地址就得跟着换。ESM 的实时绑定让所有 `import { API_BASE }` 的模块看到
|
||||
* 新值 —— 但**取快照的模块看不到**(`const B = API_BASE`),所以 api 层里
|
||||
* 一律在读的时候取,不缓存。
|
||||
*
|
||||
* 网页端(同源 /api/v1)不会被改动:那里没有账号切换,值始终是解析出来的那个。
|
||||
*/
|
||||
export let API_BASE = resolveBase();
|
||||
|
||||
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
|
||||
let bearerToken: string | null =
|
||||
@ -55,6 +65,48 @@ export function authHeaders(): Record<string, string> {
|
||||
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换"当前账号"的认证(多账号用)。
|
||||
*
|
||||
* 基地址与令牌**一起切**:账号自带 gateway,只切令牌会把这封信发到上一个
|
||||
* 账号的服务器上去(或 401)。切换后所有走单例的调用点都指向新账号。
|
||||
*
|
||||
* 代价是 api 层不能在模块作用域缓存 `API_BASE`(缓存了就只有第一次是对的)——
|
||||
* 见 `client.ts` 里的 `base()`。
|
||||
*/
|
||||
export function setActiveAuth(auth: { base: string; token: string }): void {
|
||||
const base = String(auth?.base ?? '').replace(/\/+$/, '');
|
||||
if (base) API_BASE = base;
|
||||
bearerToken = auth?.token && String(auth.token).trim() !== '' ? String(auth.token).trim() : null;
|
||||
}
|
||||
|
||||
/** 当前认证(基地址 + 令牌)的快照,供需要判断"这两个请求是不是同一个账号"的地方用。 */
|
||||
export function activeAuth(): { base: string; token: string | null } {
|
||||
return { base: API_BASE, token: bearerToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式认证的一次请求(聚合收件箱、每账号 SSE 用)。
|
||||
*
|
||||
* 为什么不复用 `request()`:那个函数把 base/令牌写死在单例上,
|
||||
* 而聚合要**同时**问多个账号 —— 借用单例就得来回切换它,
|
||||
* 期间的并发请求会串号(A 的请求带上 B 的令牌)。
|
||||
*
|
||||
* @param auth `accountAuth(account)` 的结果
|
||||
*/
|
||||
export async function fetchWithAuth(
|
||||
auth: { base: string; token: string },
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const base = String(auth?.base ?? '').replace(/\/+$/, '');
|
||||
const headers: Record<string, string> = {
|
||||
...(init.headers as Record<string, string> | undefined)
|
||||
};
|
||||
if (auth?.token) headers.Authorization = `Bearer ${auth.token}`;
|
||||
return fetch(`${base}${path}`, { ...init, headers, credentials: 'include' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
|
||||
* - EventSource(SSE)不支持自定义头
|
||||
|
||||
Reference in New Issue
Block a user