按 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`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
179 lines
7.3 KiB
TypeScript
179 lines
7.3 KiB
TypeScript
/**
|
||
* 多账号的**纯逻辑**:账号模型、增删、默认账号选择、聚合收件箱合并。
|
||
*
|
||
* 单独一个文件、不含任何 I/O,是为了让它能被直接测(见
|
||
* `test/lib/accounts.test.mjs`)。持久化在 `stores/accountStore.ts`,
|
||
* 主进程落盘在 `electron/main.cjs` 的 IPC 里 —— 那两层都只调用这里的函数,
|
||
* 于是「账号怎么合并、谁是默认」这类判据只有一份。
|
||
*
|
||
* # 设计取舍:为什么"当前账号"仍然走全局单例
|
||
*
|
||
* `api/config.ts` 里的 `API_BASE` / `bearerToken` 是**模块级单例**,
|
||
* 全部既有调用点(收件箱、会话、日历、附件……)都直接用它们。
|
||
*
|
||
* 多账号有两条路:
|
||
* 1. 把每个调用点改成"传账号"—— 改动面覆盖整个 api 层与所有组件;
|
||
* 2. **保留单例 = "当前账号的认证"**,只在两处需要跨账号的地方走显式认证:
|
||
* · 聚合收件箱(要同时问多个账号)
|
||
* · 多账号 SSE(每账号一条连接)
|
||
*
|
||
* 选 2。理由是风险与收益:单账号视图(占绝大多数用量)保持逐字节不变,
|
||
* 而聚合与 SSE 这两处本来就必须显式持有各自的 base/token。
|
||
*/
|
||
|
||
/** 聚合视图的伪账号 id(与真实账号 id 不会冲突:真实 id 是 uuid)。 */
|
||
export const AGGREGATE_ID = 'all';
|
||
|
||
/** 一个账号。字段与 `docs/MULTI-ACCOUNT-PLAN.md` 第三节一致。 */
|
||
export interface Account {
|
||
id: string;
|
||
/** 显示用名(「工作邮箱」),不是登录用户名 */
|
||
displayName: string;
|
||
/** Gateway 根地址,如 http://192.168.2.60:8180(不带 /api/v1) */
|
||
gateway: string;
|
||
/** permanent user_key,用作 Bearer */
|
||
token: string;
|
||
/** 登录用户名,仅用于下次验证,可空 */
|
||
username?: string;
|
||
/** 最近一次选中时间(ISO),用于挑默认账号 */
|
||
lastUsed?: string;
|
||
}
|
||
|
||
/** 账号的认证信息(base 已含 /api/v1)。 */
|
||
export interface AccountAuth {
|
||
base: string;
|
||
token: string;
|
||
}
|
||
|
||
/**
|
||
* 规范化 Gateway 地址。
|
||
*
|
||
* 用户会粘贴各种形态:带尾斜杠、带 `/api/v1`、带空格、写 `localhost`。
|
||
* 统一收敛成"根地址不带尾斜杠",拼 `/api/v1` 只在一处做(`accountAuth`)。
|
||
*/
|
||
export function normalizeGateway(raw: string): string {
|
||
let s = String(raw ?? '').trim();
|
||
if (s === '') return '';
|
||
// 补协议:用户常只写 ip:port
|
||
if (!/^https?:\/\//i.test(s)) s = `http://${s}`;
|
||
s = s.replace(/\/+$/, '');
|
||
// 粘贴了完整 API 地址时把尾巴去掉,避免拼出 /api/v1/api/v1
|
||
s = s.replace(/\/api\/v1$/i, '');
|
||
return s.replace(/\/+$/, '');
|
||
}
|
||
|
||
/** 由 gateway / username 派生一个默认显示名(用户可改)。 */
|
||
export function deriveDisplayName(gateway: string, username?: string): string {
|
||
const host = String(gateway ?? '')
|
||
.replace(/^https?:\/\//i, '')
|
||
.replace(/\/.*$/, '');
|
||
if (username && username.trim()) return `${username.trim()}@${host || '?'}`;
|
||
return host || '未命名账号';
|
||
}
|
||
|
||
/** 账号的认证信息。`base` 是真正要拼在路径前的那一段。 */
|
||
export function accountAuth(a: Pick<Account, 'gateway' | 'token'>): AccountAuth {
|
||
return { base: `${normalizeGateway(a.gateway)}/api/v1`, token: String(a.token ?? '') };
|
||
}
|
||
|
||
/**
|
||
* 账号是否够格入库/使用。
|
||
*
|
||
* 判据刻意宽松(只查"有地址且有令牌"):把格式校验做严了,
|
||
* 用户粘贴一个自建网关的相对地址就会被拒,而那种情况下唯一该说话的是
|
||
* 「验证连通性」那一步(`GET /auth/me`)。
|
||
*/
|
||
export function isUsableAccount(a: Partial<Account> | null | undefined): boolean {
|
||
if (!a) return false;
|
||
return normalizeGateway(a.gateway ?? '') !== '' && String(a.token ?? '').trim() !== '';
|
||
}
|
||
|
||
/**
|
||
* 按 id 插入或更新(同 id 覆盖)。
|
||
*
|
||
* 幂等:同一个 id 更新两次不会产生两条 —— 添加账号时如果用户重复粘贴同一个
|
||
* 网关+令牌,调用方应当先按 `sameIdentity` 去重,而不是靠这里。
|
||
*/
|
||
export function upsertAccount(list: Account[], acct: Account): Account[] {
|
||
const out = list.slice();
|
||
const i = out.findIndex(a => a.id === acct.id);
|
||
if (i >= 0) out[i] = { ...out[i], ...acct };
|
||
else out.push(acct);
|
||
return out;
|
||
}
|
||
|
||
/** 删除账号;不存在的 id 返回原列表(幂等)。 */
|
||
export function removeAccount(list: Account[], id: string): Account[] {
|
||
return list.filter(a => a.id !== id);
|
||
}
|
||
|
||
/**
|
||
* 两个账号是不是"同一个身份"(同网关 + 同令牌)。
|
||
*
|
||
* 用来挡住重复添加:uuid 每次新建都不同,所以不能靠 id 判重。
|
||
* 比较前先规范化网关 —— 否则 `http://host:8180` 与 `host:8180/` 会被当成两个。
|
||
*/
|
||
export function sameIdentity(a: Pick<Account, 'gateway' | 'token'>, b: Pick<Account, 'gateway' | 'token'>): boolean {
|
||
return normalizeGateway(a.gateway) === normalizeGateway(b.gateway) && String(a.token ?? '') === String(b.token ?? '');
|
||
}
|
||
|
||
/**
|
||
* 挑默认账号:最近使用过的那个(`lastUsed` 最大)。
|
||
*
|
||
* 都没有 lastUsed 时取列表第一个 —— "第一个添加的账号"是方案里写死的兜底,
|
||
* 而它的顺序由列表本身决定,所以调用方要保持列表顺序稳定(追加而非重排)。
|
||
*/
|
||
export function pickDefaultAccountId(list: Account[]): string {
|
||
if (list.length === 0) return '';
|
||
let best = list[0];
|
||
for (const a of list) {
|
||
if (!a.lastUsed) continue;
|
||
if (!best.lastUsed || a.lastUsed > best.lastUsed) best = a;
|
||
}
|
||
return best.id;
|
||
}
|
||
|
||
/** 把某个账号标记为"刚用过"(不改其它字段、不改顺序)。 */
|
||
export function touchAccount(list: Account[], id: string, now = new Date().toISOString()): Account[] {
|
||
return list.map(a => (a.id === id ? { ...a, lastUsed: now } : a));
|
||
}
|
||
|
||
/** 带账号归属的邮件(聚合列表用)。 */
|
||
export type AccountMail<T> = T & { account_id: string; account_name: string };
|
||
|
||
/**
|
||
* 合并多个账号的收件箱。
|
||
*
|
||
* 三条判据(都有测试钉着):
|
||
* 1. **按时间倒序** —— 聚合列表看起来要像一份收件箱,不是几个收件箱拼接。
|
||
* 时间缺失的排在最后(不能因为缺字段就把它排到最前)。
|
||
* 2. **同一 mail_id 只留一条** —— 两个账号可能都在同一条线索里(同一封信
|
||
* 被抄送给了两边)。重复会让同一封信出现两次,而删除/标记已读只在其中一个
|
||
* 账号上生效,于是"点过了还在"。
|
||
* 3. **归属标注保留** —— 每条带上 `account_id` / `account_name`,
|
||
* 界面才有徽标可画;同时第一条胜出,所以"这封信属于谁"是确定的。
|
||
*/
|
||
export function mergeInboxes<T extends { mail_id?: string; created_at?: string }>(
|
||
perAccount: { account: Pick<Account, 'id' | 'displayName'>; mails: T[] }[]
|
||
): AccountMail<T>[] {
|
||
const seen = new Set<string>();
|
||
const out: AccountMail<T>[] = [];
|
||
for (const { account, mails } of perAccount) {
|
||
for (const m of mails ?? []) {
|
||
const key = String(m?.mail_id ?? '');
|
||
if (key && seen.has(key)) continue;
|
||
if (key) seen.add(key);
|
||
out.push({ ...m, account_id: account.id, account_name: account.displayName });
|
||
}
|
||
}
|
||
out.sort((a, b) => {
|
||
const ta = String(a.created_at ?? '');
|
||
const tb = String(b.created_at ?? '');
|
||
if (ta === tb) return 0;
|
||
if (!ta) return 1; // 缺时间的排最后
|
||
if (!tb) return -1;
|
||
return ta < tb ? 1 : -1; // 倒序
|
||
});
|
||
return out;
|
||
}
|