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:
@ -1,6 +1,7 @@
|
||||
import { Suspense, lazy, useEffect } from 'react';
|
||||
import { connectSSE } from './api/sse';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { useAccountStore } from './stores/accountStore';
|
||||
import { useMailStore } from './stores/mailStore';
|
||||
import { useSessionStore } from './stores/sessionStore';
|
||||
import { useContactStore } from './stores/contactStore';
|
||||
@ -50,8 +51,15 @@ export default function App() {
|
||||
const removeSessionLocally = useContactStore(s => s.removeSessionLocally);
|
||||
|
||||
// 启动时检测初始化状态 / 登录态
|
||||
//
|
||||
// 顺序要紧:**先加载账号**(它会把选中账号的认证写进 api 单例),再 bootstrap。
|
||||
// 反过来的话,bootstrap 里那几次请求会用旧令牌发出去,表现为"启动瞬间
|
||||
// 显示未登录、过一会儿自己好了"。
|
||||
useEffect(() => {
|
||||
bootstrap();
|
||||
(async () => {
|
||||
await useAccountStore.getState().load();
|
||||
bootstrap();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 登出后清理客户端状态,避免脏数据残留
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
import type { User } from '../types';
|
||||
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types';
|
||||
import { API_BASE, authHeaders, withToken } from './config';
|
||||
import { API_BASE, authHeaders, withToken, fetchWithAuth } from './config';
|
||||
|
||||
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
|
||||
export { API_BASE, setToken, getToken, authHeaders, withToken, fetchWithAuth } from './config';
|
||||
|
||||
const BASE = API_BASE;
|
||||
/**
|
||||
* 基地址**每次读**(不是模块加载时缓存)。
|
||||
*
|
||||
* 多账号下 `API_BASE` 会随当前账号变化 —— 缓存成 `const` 的话,
|
||||
* 切换账号后只有第一次请求指向新网关,之后又回到旧网关。
|
||||
*/
|
||||
function base(): string {
|
||||
return API_BASE;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@ -30,7 +38,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, init);
|
||||
const res = await fetch(`${base()}${path}`, init);
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
@ -247,6 +255,40 @@ export async function getInbox(status = 'all', limit = 50) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用**指定的**账号认证取收件箱(聚合视图用)。
|
||||
*
|
||||
* 与 `getInbox` 的区别:那条走认证单例(= 当前账号),而聚合要同时问多个账号。
|
||||
* 借用单例来回切换会让并发请求串号(A 的请求带上 B 的令牌)——
|
||||
* 所以这里显式带认证,且**不碰单例**。
|
||||
*
|
||||
* 返回体与 `getInbox` 一致,调用方才能真正把两者混着用。
|
||||
*/
|
||||
export async function getInboxWithAuth(
|
||||
auth: { base: string; token: string },
|
||||
status = 'all',
|
||||
limit = 50
|
||||
): Promise<{ mails: Mail[]; total: number }> {
|
||||
const res = await fetchWithAuth(auth, `/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`);
|
||||
const text = await res.text();
|
||||
let body: any = {};
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
if (!res.ok) {
|
||||
// 错误要带上账号信息是调用方的事(它知道是哪个账号),这里只把状态与
|
||||
// 服务端原话抛出去 —— 聚合里"某个账号挂了"必须能说清是哪一种挂法。
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
`HTTP ${res.status}${body?.error ? `:${body.error}` : ''}`,
|
||||
body
|
||||
);
|
||||
}
|
||||
return { mails: body?.mails ?? [], total: body?.total ?? (body?.mails?.length ?? 0) };
|
||||
}
|
||||
|
||||
export async function getSent() {
|
||||
return request<{ mails: Mail[] }>('GET', '/me/mail/sent');
|
||||
}
|
||||
@ -292,7 +334,7 @@ export async function uploadAttachment(file: File, onProgress?: (pct: number) =>
|
||||
if (onProgress) {
|
||||
return new Promise<{ attachment: Attachment }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${BASE}/me/attachments`);
|
||||
xhr.open('POST', `${base()}/me/attachments`);
|
||||
xhr.withCredentials = true;
|
||||
for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v);
|
||||
xhr.upload.onprogress = e => {
|
||||
@ -317,7 +359,7 @@ export async function uploadAttachment(file: File, onProgress?: (pct: number) =>
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}/me/attachments`, {
|
||||
const res = await fetch(`${base()}/me/attachments`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
// 不设 Content-Type:multipart 的 boundary 要交给浏览器生成
|
||||
@ -341,7 +383,7 @@ export async function deleteAttachment(id: string) {
|
||||
* Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。
|
||||
*/
|
||||
export function attachmentURL(id: string) {
|
||||
return withToken(`${BASE}/me/attachments/${id}`);
|
||||
return withToken(`${base()}/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/** 人类可读的字节数 */
|
||||
|
||||
@ -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)不支持自定义头
|
||||
|
||||
189
client/electron/src/components/AccountList.tsx
Normal file
189
client/electron/src/components/AccountList.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 多账号管理(账号页里的一段):列出已添加账号、添加新账号、删除。
|
||||
*
|
||||
* # 添加时**真的去验证**连通性
|
||||
*
|
||||
* 粘贴一个错的密钥不会报错——它只会表现为"这个账号的收件箱一直是空的"。
|
||||
* 所以添加流程必须调一次 `GET /auth/me`:
|
||||
* 成功 → 显示用户名(用户能确认"这是我要的那个账号")
|
||||
* 401 → 明确说"密钥无效",不写进账号列表
|
||||
* 网络错 → 说清是哪一种(连不上 / 超时),因为这两种的修法不同
|
||||
*
|
||||
* 用「显式认证」发这个请求(`fetchWithAuth`),不碰当前账号的认证单例 ——
|
||||
* 否则验证一个坏账号的过程会把当前账号的令牌换掉。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { fetchWithAuth } from '../api/config';
|
||||
import { deriveDisplayName, normalizeGateway } from '../lib/accounts';
|
||||
import { activeAccount, useAccountStore } from '../stores/accountStore';
|
||||
|
||||
export default function AccountList() {
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const activeId = useAccountStore(s => s.activeId);
|
||||
const ephemeral = useAccountStore(s => s.ephemeralStorage);
|
||||
const storageFile = useAccountStore(s => s.storageFile);
|
||||
const addAccount = useAccountStore(s => s.add);
|
||||
const removeAccount = useAccountStore(s => s.remove);
|
||||
const setActive = useAccountStore(s => s.setActive);
|
||||
const current = useAccountStore(activeAccount);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gateway, setGateway] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setMsg(null);
|
||||
const g = normalizeGateway(gateway);
|
||||
const t = token.trim();
|
||||
if (!g) return setMsg({ kind: 'err', text: '请填写 Gateway 地址' });
|
||||
if (!t) return setMsg({ kind: 'err', text: '请填写用户密钥' });
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
// 先验证,再入库 —— 顺序反过来就会出现"列表里有个永远空的账号"
|
||||
const res = await fetchWithAuth({ base: `${g}/api/v1`, token: t }, '/auth/me');
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setBusy(false);
|
||||
return setMsg({ kind: 'err', text: '密钥无效(服务端返回 401)——请确认用的是用户密钥' });
|
||||
}
|
||||
if (!res.ok) {
|
||||
setBusy(false);
|
||||
return setMsg({ kind: 'err', text: `服务端返回 ${res.status}${body?.error ? `:${body.error}` : ''}` });
|
||||
}
|
||||
const username = body?.user?.username || body?.username || '';
|
||||
const r = await addAccount({
|
||||
gateway: g,
|
||||
token: t,
|
||||
username,
|
||||
displayName: name.trim() || deriveDisplayName(g, username)
|
||||
});
|
||||
setBusy(false);
|
||||
if (!r.ok) return setMsg({ kind: 'err', text: r.error || '添加失败' });
|
||||
setMsg({ kind: 'ok', text: `已添加${username ? `(${username})` : ''}` });
|
||||
setGateway('');
|
||||
setToken('');
|
||||
setName('');
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
// 连不上 / 超时 / CORS 都要能分辨:这里至少把原始错误原样说出来
|
||||
setMsg({ kind: 'err', text: `连不上:${(e as Error)?.message || e}` });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="account-list">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="text-xs font-medium text-gray-500">多账号</h3>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="account-add-toggle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="ml-auto text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{open ? '取消' : '+ 添加账号'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 存储位置如实说:网页端退到 localStorage,那不是安全存储 */}
|
||||
<p className="text-[11px] text-gray-400 mb-3">
|
||||
{ephemeral
|
||||
? '账号保存在浏览器 localStorage(当前不在桌面端,这不是加密存储)'
|
||||
: `账号保存在本机文件:${storageFile || '(未知路径)'}(权限 600)`}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
{accounts.map(a => {
|
||||
const usable = Boolean(a.token && a.gateway);
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
data-testid={`account-row-${a.id}`}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-gray-800 truncate">{a.displayName}</span>
|
||||
<span className="block text-[11px] text-gray-400 truncate">
|
||||
{a.gateway}
|
||||
{a.username ? ` · ${a.username}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{!usable && <span className="text-[11px] text-amber-600">缺密钥</span>}
|
||||
{a.id === current?.id ? (
|
||||
<span className="text-[11px] text-blue-600">当前</span>
|
||||
) : a.id !== activeId ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`account-use-${a.id}`}
|
||||
onClick={() => setActive(a.id)}
|
||||
className="text-[11px] text-gray-600 hover:underline"
|
||||
>
|
||||
切换
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`account-del-${a.id}`}
|
||||
onClick={() => removeAccount(a.id)}
|
||||
className="text-[11px] text-red-600 hover:underline"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{accounts.length === 0 && <li className="text-xs text-gray-400">还没有添加任何账号</li>}
|
||||
</ul>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-gray-50 border border-gray-200">
|
||||
<input
|
||||
data-testid="account-gateway"
|
||||
value={gateway}
|
||||
onChange={e => setGateway(e.target.value)}
|
||||
placeholder="Gateway 地址,如 http://192.168.2.60:8180"
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300"
|
||||
/>
|
||||
<input
|
||||
data-testid="account-token"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
placeholder="用户密钥(user key,64 位十六进制)"
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300 font-mono"
|
||||
/>
|
||||
<input
|
||||
data-testid="account-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="显示名(可留空,默认用 用户名@主机)"
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="account-add-submit"
|
||||
disabled={busy}
|
||||
onClick={submit}
|
||||
className="tap px-3 py-1.5 text-sm rounded-md bg-blue-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? '验证中…' : '验证并添加'}
|
||||
</button>
|
||||
<p className="text-[11px] text-gray-500">添加前会调用一次 /auth/me 验证密钥,通过才写入。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
data-testid="account-msg"
|
||||
className={`mt-2 text-xs ${msg.kind === 'ok' ? 'text-green-600' : 'text-red-600'}`}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { LockIcon, LogoutIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import AccountList from './AccountList';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import BackgroundPicker from './BackgroundPicker';
|
||||
|
||||
@ -199,6 +200,11 @@ export default function AccountPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 多账号(docs/MULTI-ACCOUNT-PLAN.md) */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<AccountList />
|
||||
</section>
|
||||
|
||||
{/* 客户端连接密钥 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<KeyPanel
|
||||
|
||||
138
client/electron/src/components/AccountSwitcher.tsx
Normal file
138
client/electron/src/components/AccountSwitcher.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 账号选择器(顶栏下拉):像邮箱 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>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,8 @@ import { useUIStore } from '../stores/uiStore';
|
||||
import type { Mail } from '../types';
|
||||
import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups';
|
||||
import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons';
|
||||
import AccountSwitcher from './AccountSwitcher';
|
||||
import { isAggregate, useAccountStore } from '../stores/accountStore';
|
||||
import { participantAddress } from '../lib/replyTarget';
|
||||
|
||||
export default function MailList() {
|
||||
@ -20,6 +22,11 @@ export default function MailList() {
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const clearSession = useSessionStore(s => s.clearSession);
|
||||
|
||||
// 聚合视图("全部邮箱"):列表要画账号徽标、并在某个账号取失败时提示
|
||||
const activeId = useAccountStore(s => s.activeId);
|
||||
const accountErrors = useMailStore(s => s.accountErrors);
|
||||
const aggregate = isAggregate({ activeId });
|
||||
|
||||
// 哪些会话组被展开。默认全部折叠 —— 收件箱的问题正是「一次任务的几十封信
|
||||
// 淹掉其他任务」,默认展开等于没分组
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
@ -66,6 +73,7 @@ export default function MailList() {
|
||||
<div className="w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
|
||||
<div className="px-4 py-3.5 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||
<AccountSwitcher />
|
||||
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
|
||||
「有几件事」,后者只是流量 */}
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
@ -75,6 +83,18 @@ export default function MailList() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件,
|
||||
而界面看起来完全正常 —— 这正是"聚合"最容易骗人的失败方式 */}
|
||||
{aggregate && accountErrors.length > 0 && (
|
||||
<div
|
||||
data-testid="account-errors"
|
||||
className="mx-2.5 mt-2 px-2.5 py-1.5 rounded-md bg-amber-50 border border-amber-200 text-[11px] text-amber-800"
|
||||
>
|
||||
有 {accountErrors.length} 个账号没取到:
|
||||
{accountErrors.map(e => `${e.account}(${e.error})`).join(';')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2.5 space-y-1">
|
||||
{groups.map(g =>
|
||||
isFlatGroup(g) ? (
|
||||
@ -84,6 +104,7 @@ export default function MailList() {
|
||||
active={currentMail?.mail_id === g.latest.mail_id}
|
||||
showTo={isSent}
|
||||
onClick={() => pick(g.latest)}
|
||||
accountName={aggregate ? g.latest.account_name : undefined}
|
||||
/>
|
||||
) : (
|
||||
<SessionGroup
|
||||
@ -222,12 +243,15 @@ function MailItem({
|
||||
active,
|
||||
showTo,
|
||||
onClick,
|
||||
compact = false
|
||||
compact = false,
|
||||
accountName
|
||||
}: {
|
||||
mail: Mail;
|
||||
active: boolean;
|
||||
showTo: boolean;
|
||||
onClick: () => void;
|
||||
/** 聚合视图下的账号归属徽标(单账号视图传 undefined,不画) */
|
||||
accountName?: string;
|
||||
/** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
@ -276,6 +300,15 @@ function MailItem({
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{accountName && (
|
||||
<span
|
||||
data-testid="account-badge"
|
||||
className="text-[10px] leading-4 px-1.5 rounded-full bg-gray-100 text-gray-600 shrink-0 max-w-[80px] truncate"
|
||||
title={`来自账号:${accountName}`}
|
||||
>
|
||||
{accountName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-3xs text-gray-500 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
|
||||
178
client/electron/src/lib/accounts.ts
Normal file
178
client/electron/src/lib/accounts.ts
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 多账号的**纯逻辑**:账号模型、增删、默认账号选择、聚合收件箱合并。
|
||||
*
|
||||
* 单独一个文件、不含任何 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;
|
||||
}
|
||||
209
client/electron/src/stores/accountStore.ts
Normal file
209
client/electron/src/stores/accountStore.ts
Normal file
@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 账号 store:账号列表、当前选中(含聚合)、以及"选中变化 → 认证单例跟着变"。
|
||||
*
|
||||
* # 关键约定:`config.ts` 的单例 = **当前账号的认证**
|
||||
*
|
||||
* `src/api/config.ts` 里的 `API_BASE` / `bearerToken` 是模块级单例,全部既有
|
||||
* 调用点(收件箱、会话、日历、附件…)都用它们。多账号没有把它们改成
|
||||
* "每个调用点传账号"(那要动整个 api 层与所有组件),而是:
|
||||
*
|
||||
* **单例始终等于"当前选中账号"的认证,切换时由这里同步。**
|
||||
*
|
||||
* 只有两处必须显式持有各自的认证,它们本来就需要跨账号:
|
||||
* · 聚合收件箱(同时问多个账号,见 mailStore.fetchInbox)
|
||||
* · 多账号 SSE(每账号一条连接,下一轮)
|
||||
*
|
||||
* 于是单账号视图的代码路径逐字节不变。
|
||||
*
|
||||
* # 持久化
|
||||
*
|
||||
* 优先走主进程 IPC(`window.agentmail.accounts`,落 `userData/accounts.json`,
|
||||
* 0600 原子写)。没有 IPC 时(浏览器里跑同一份前端)退到 localStorage ——
|
||||
* 那不是安全存储,网页端首次使用时界面会明说这一点(不假装它是安全的)。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
AGGREGATE_ID,
|
||||
type Account,
|
||||
accountAuth,
|
||||
deriveDisplayName,
|
||||
isUsableAccount,
|
||||
normalizeGateway,
|
||||
pickDefaultAccountId,
|
||||
removeAccount as removeFrom,
|
||||
sameIdentity,
|
||||
touchAccount,
|
||||
upsertAccount
|
||||
} from '../lib/accounts';
|
||||
import { setActiveAuth } from '../api/config';
|
||||
|
||||
interface AccountState {
|
||||
accounts: Account[];
|
||||
/** 当前选中:某个账号 id,或 AGGREGATE_ID(聚合) */
|
||||
activeId: string;
|
||||
/** 持久化位置(供界面显示"账号存在哪") */
|
||||
storageFile: string;
|
||||
/** 网页端(无 IPC)时为 true —— 界面据此提示"令牌存在 localStorage" */
|
||||
ephemeralStorage: boolean;
|
||||
loaded: boolean;
|
||||
error: string | null;
|
||||
|
||||
load: () => Promise<void>;
|
||||
add: (input: Omit<Account, 'id' | 'displayName' | 'lastUsed'> & { displayName?: string }) => Promise<{ ok: boolean; error?: string }>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
setActive: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 主进程 IPC(仅 Electron 里有)。 */
|
||||
function bridge(): { load: () => Promise<any>; save: (a: Account[]) => Promise<any> } | null {
|
||||
const w = globalThis as any;
|
||||
return w?.agentmail?.accounts ?? null;
|
||||
}
|
||||
|
||||
const LS_KEY = 'agentmail.accounts.v1';
|
||||
|
||||
async function persist(accounts: Account[]): Promise<{ file: string; ephemeral: boolean }> {
|
||||
const b = bridge();
|
||||
if (b) {
|
||||
const r = await b.save(accounts);
|
||||
if (!r?.ok) throw new Error(r?.error || '主进程保存失败');
|
||||
return { file: String(r.file || ''), ephemeral: false };
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(accounts));
|
||||
} catch (e) {
|
||||
throw new Error(`浏览器存储不可用:${(e as Error)?.message || e}`);
|
||||
}
|
||||
return { file: '', ephemeral: true };
|
||||
}
|
||||
|
||||
function readLocal(): Account[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 把选中账号的认证同步到 api 单例。聚合模式用第一个可用账号(发信要有身份)。 */
|
||||
function syncAuth(accounts: Account[], activeId: string): void {
|
||||
const target =
|
||||
activeId === AGGREGATE_ID
|
||||
? accounts.find(a => isUsableAccount(a))
|
||||
: accounts.find(a => a.id === activeId);
|
||||
if (target && isUsableAccount(target)) {
|
||||
const { base, token } = accountAuth(target);
|
||||
setActiveAuth({ base, token });
|
||||
}
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
const c = globalThis.crypto as Crypto | undefined;
|
||||
if (c?.randomUUID) return c.randomUUID();
|
||||
return `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
export const useAccountStore = create<AccountState>((set, get) => ({
|
||||
accounts: [],
|
||||
activeId: AGGREGATE_ID,
|
||||
storageFile: '',
|
||||
ephemeralStorage: false,
|
||||
loaded: false,
|
||||
error: null,
|
||||
|
||||
load: async () => {
|
||||
const b = bridge();
|
||||
let accounts: Account[] = [];
|
||||
let file = '';
|
||||
let ephemeral = false;
|
||||
if (b) {
|
||||
const r = await b.load();
|
||||
accounts = Array.isArray(r?.accounts) ? r.accounts : [];
|
||||
file = String(r?.file || '');
|
||||
if (r && r.ok === false) set({ error: `读取账号失败:${r.error}` });
|
||||
} else {
|
||||
accounts = readLocal();
|
||||
ephemeral = true;
|
||||
}
|
||||
// 选中项:记住上次选中的(落盘在 activeId 里不合适——它是视图状态,
|
||||
// 这里用"最近使用"推导,避免多一个持久化字段和它的迁移问题)
|
||||
const activeId = pickDefaultAccountId(accounts) || AGGREGATE_ID;
|
||||
syncAuth(accounts, activeId);
|
||||
set({ accounts, activeId, storageFile: file, ephemeralStorage: ephemeral, loaded: true });
|
||||
},
|
||||
|
||||
add: async input => {
|
||||
const gateway = normalizeGateway(input.gateway);
|
||||
const token = String(input.token ?? '').trim();
|
||||
if (!gateway) return { ok: false, error: '请填写 Gateway 地址' };
|
||||
if (!token) return { ok: false, error: '请填写用户密钥(user key)' };
|
||||
|
||||
const { accounts } = get();
|
||||
// 同一身份重复添加会让收件箱出现两份同样的邮件、SSE 也多一条 —— 直接挡住
|
||||
if (accounts.some(a => sameIdentity(a, { gateway, token }))) {
|
||||
return { ok: false, error: '这个账号已经添加过了(同一 Gateway + 同一密钥)' };
|
||||
}
|
||||
|
||||
const acct: Account = {
|
||||
id: newId(),
|
||||
displayName: (input.displayName || '').trim() || deriveDisplayName(gateway, input.username),
|
||||
gateway,
|
||||
token,
|
||||
username: input.username,
|
||||
lastUsed: new Date().toISOString()
|
||||
};
|
||||
const next = upsertAccount(accounts, acct);
|
||||
set({ accounts: next, activeId: acct.id, error: null });
|
||||
syncAuth(next, acct.id);
|
||||
try {
|
||||
const r = await persist(next);
|
||||
set({ storageFile: r.file, ephemeralStorage: r.ephemeral });
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
set({ error: `保存失败:${(e as Error)?.message || e}` });
|
||||
return { ok: false, error: `保存失败:${(e as Error)?.message || e}` };
|
||||
}
|
||||
},
|
||||
|
||||
remove: async id => {
|
||||
const { accounts, activeId } = get();
|
||||
const next = removeFrom(accounts, id);
|
||||
const nextActive = activeId === id ? (next[0]?.id ?? AGGREGATE_ID) : activeId;
|
||||
set({ accounts: next, activeId: nextActive, error: null });
|
||||
syncAuth(next, nextActive);
|
||||
try {
|
||||
const r = await persist(next);
|
||||
set({ storageFile: r.file, ephemeralStorage: r.ephemeral });
|
||||
} catch (e) {
|
||||
set({ error: `保存失败:${(e as Error)?.message || e}` });
|
||||
}
|
||||
},
|
||||
|
||||
setActive: async id => {
|
||||
const { accounts } = get();
|
||||
const next = id === AGGREGATE_ID ? accounts : touchAccount(accounts, id);
|
||||
set({ activeId: id, accounts: next });
|
||||
syncAuth(next, id);
|
||||
// lastUsed 变了要落盘,否则下次启动的默认账号会退回旧值
|
||||
try {
|
||||
await persist(next);
|
||||
} catch {
|
||||
/* 落盘失败不该挡住切换:内存里已经切好了,下次启动最多是默认账号不对 */
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
/** 当前选中的账号(聚合模式下为 null —— 它不是一个账号)。 */
|
||||
export function activeAccount(s: { accounts: Account[]; activeId: string }): Account | null {
|
||||
if (s.activeId === AGGREGATE_ID) return null;
|
||||
return s.accounts.find(a => a.id === s.activeId) ?? null;
|
||||
}
|
||||
|
||||
/** 是否处于聚合视图。 */
|
||||
export function isAggregate(s: { activeId: string }): boolean {
|
||||
return s.activeId === AGGREGATE_ID;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Mail } from '../types';
|
||||
import * as api from '../api/client';
|
||||
import { accountAuth, mergeInboxes, normalizeGateway } from '../lib/accounts';
|
||||
import { isAggregate, useAccountStore } from './accountStore';
|
||||
|
||||
interface MailState {
|
||||
inbox: Mail[];
|
||||
@ -8,6 +10,14 @@ interface MailState {
|
||||
currentMail: Mail | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* 聚合模式下**某个账号**取失败的原因(账号名 + 原因)。
|
||||
*
|
||||
* 单独一个字段而不是塞进 `error`:`error` 会让整个列表变成错误态,
|
||||
* 而这里更常见的是"两个账号里有一个挂了" —— 那时其余邮件仍应显示,
|
||||
* 只在列表上方标明少了一份。静默丢一整个账号才是真的会骗人。
|
||||
*/
|
||||
accountErrors: { account: string; error: string }[];
|
||||
|
||||
fetchInbox: (status?: string) => Promise<void>;
|
||||
fetchSent: () => Promise<void>;
|
||||
@ -28,18 +38,68 @@ interface MailState {
|
||||
export const useMailStore = create<MailState>((set, get) => ({
|
||||
inbox: [],
|
||||
sent: [],
|
||||
accountErrors: [],
|
||||
currentMail: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchInbox: async (status = 'all') => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getInbox(status);
|
||||
set({ inbox: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
const acc = useAccountStore.getState();
|
||||
const usable = acc.accounts.filter(a => a.token && a.gateway);
|
||||
|
||||
// 单账号(或只有一个账号):走原来的路径,逐字节不变。
|
||||
// 聚合只在**真的有两个以上可用账号**时才发生 —— 否则"全部邮箱"与
|
||||
// 单账号看到的是同一份数据,多绕一圈只会多出失败面。
|
||||
if (!isAggregate(acc) || usable.length < 2) {
|
||||
set({ loading: true, error: null, accountErrors: [] });
|
||||
try {
|
||||
const { mails } = await api.getInbox(status);
|
||||
set({ inbox: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 只合并**同一网关**的账号。
|
||||
//
|
||||
// 单例基地址 = 当前账号的网关,打开邮件/标已读这些动作都走它。若把另一个
|
||||
// 网关的邮件混进列表,点开时会去问第一个账号的服务器 —— 要么 404,
|
||||
// 要么更糟:mail_id 恰好撞上就打开了别人的信。所以宁可如实排除,
|
||||
// 并在列表上方说明(这是可见的缺失,不是静默少一份)。
|
||||
const hostGateway = normalizeGateway(usable[0].gateway);
|
||||
const sameGateway = usable.filter(a => normalizeGateway(a.gateway) === hostGateway);
|
||||
const otherGateway = usable.filter(a => normalizeGateway(a.gateway) !== hostGateway);
|
||||
|
||||
set({ loading: true, error: null, accountErrors: [] });
|
||||
const results = await Promise.allSettled(
|
||||
sameGateway.map(async a => ({ account: a, ...(await api.getInboxWithAuth(accountAuth(a), status)) }))
|
||||
);
|
||||
|
||||
const ok: { account: typeof usable[number]; mails: Mail[] }[] = [];
|
||||
const failures: { account: string; error: string }[] = otherGateway.map(a => ({
|
||||
account: a.displayName,
|
||||
error: `在另一个网关(${a.gateway}),未参与聚合 —— 切到该账号可单独查看`
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
const a = sameGateway[i];
|
||||
if (r.status === 'fulfilled') ok.push({ account: r.value.account, mails: r.value.mails || [] });
|
||||
else {
|
||||
// ★ 某个账号取不到**必须说出来**:静默丢掉它,聚合列表会少一整份邮件,
|
||||
// 而界面看起来完全正常(这正是"聚合"最容易骗人的失败方式)。
|
||||
failures.push({
|
||||
account: a.displayName,
|
||||
error: r.reason instanceof Error ? r.reason.message : String(r.reason)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
set({
|
||||
inbox: mergeInboxes(ok),
|
||||
loading: false,
|
||||
accountErrors: failures,
|
||||
error: failures.length && ok.length === 0 ? `全部账号都取不到邮件:${failures.map(f => f.account).join('、')}` : null
|
||||
});
|
||||
},
|
||||
|
||||
fetchSent: async () => {
|
||||
|
||||
@ -87,6 +87,16 @@ export interface Attachment {
|
||||
}
|
||||
|
||||
export interface Mail {
|
||||
/**
|
||||
* 这封信属于哪个账号 —— **只在聚合视图下存在**。
|
||||
*
|
||||
* 由前端 `mergeInboxes()` 在合并时附加(网关按账号返回收件箱,它自己
|
||||
* 不知道"聚合"这回事,所以后端不会给这个字段)。单账号视图下为 undefined,
|
||||
* 界面据此决定要不要画账号徽标。
|
||||
*/
|
||||
account_id?: string;
|
||||
account_name?: string;
|
||||
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
parent_mail_id: string | null;
|
||||
|
||||
Reference in New Issue
Block a user