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:
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user