Files
MailUI4Agents/web/src/components/Sidebar.tsx
JianFeeeee 9d4718a412 feat: SSE Last-Event-ID 补投 + 连接状态指示 + 限速器 DB 化
## SSE Last-Event-ID 补投

EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了——
所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。

改为 per-user 事件环形缓冲区(500 条,~100KB/用户,20 在线 ≈ 2MB):
每次 Broadcast/SendToUser/SendToAgent 同时写入对应用户的缓冲区;
AddClient 时取 Last-Event-ID 头,找到该 ID 的位置后从下一条回放。
找不到 ID 说明事件已被覆盖(缓冲区溢出),从头回放全部。

事件 ID 用全局递增序列号(非 UUID),EventSource 的 Last-Event-ID
就是靠这个 ID 记住断点的。

新增测试:缓冲区回放、溢出行为、并发安全(10 goroutine × 200 次 push)、
端到端重连验证(SendToUser → 带 Last-Event-ID 的 AddClient → 补投)。

## 连接状态指示器

Sidebar 用户头像右下角的小圆点:绿=已连接,黄=连接中,橙=重连中,红=断开。
NarrowNav 底栏也有(移动端)。

SSE 模块新增 onSSEStatus/getSSEStatus 接口,onerror/onopen 驱动状态变化。
状态点用 absolute 定位在头像边缘,不遮挡文字。

## 限速器 DB 化(解决多实例部署时的计数漂移)

原实现:LoginLimiter 与 sessionRateLimiter 都是进程内内存计数器。
多实例部署时各自独立计数,等效上限变成 N 倍。

改为 rate_limits 表(bucket + ts),两个限速器共享同一套基础设施:
- LoginLimiter:bucket="login:<username>",COUNT(*) >= 5 → 锁定 5 分钟
- sessionRateLimiter:bucket="session:<agent_name>",COUNT(*) >= 20/h → 拒绝

判断与写入在同一个 BEGIN IMMEDIATE 事务里——SQLite 的 IMMEDIATE
在事务开始时获取 RESERVED 锁,防并发写事务同时进入 COMMIT 阶段。
实测 80 并发下恰好放行 20 次(旧内存版同样通过,但 DB 版才能多实例共享)。

DB 不可用时放行(宁可放开限速也不能让用户完全无法使用)。
新建 rate_limits 表迁移(SQLite + PG 两版)。
2026-09-02 14:33:41 +08:00

117 lines
4.4 KiB
TypeScript

import { useUIStore, type ViewMode } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import {
InboxIcon,
SentIcon,
ContactsIcon,
ComposeIcon,
UsersIcon,
LogoutIcon
} from './icons';
import { ConnectionIndicator } from './ConnectionIndicator';
const navItems: {
short: string;
title: string;
mode: ViewMode;
Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean;
}[] = [
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
export default function Sidebar() {
const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout);
const isAdmin = user?.role === 'admin';
return (
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900"
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
>
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {
const active = viewMode === mode && !composing;
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
return (
<button
key={mode}
onClick={() => setViewMode(mode)}
title={title}
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
active
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
}`}
>
<Icon />
<span className="text-[9px] leading-none">{short}</span>
{badge > 0 && (
<span
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
}`}
>
{badge > 99 ? '99+' : badge}
</span>
)}
</button>
);
})}
<div className="flex-1" />
<button
onClick={() => startCompose()}
title="新建邮件"
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
}`}
>
<ComposeIcon />
<span className="text-[9px] leading-none"></span>
</button>
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
<button
onClick={() => setViewMode('account')}
title={`${user?.display_name || user?.username}(点击管理账号)`}
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
viewMode === 'account' && !composing
? 'bg-blue-500 text-white'
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
}`}
>
{(user?.display_name || user?.username || '?').slice(0, 2)}
{/* 连接状态点:不遮挡文字,贴在右下角 */}
<span className="absolute -bottom-0.5 -right-0.5">
<ConnectionIndicator />
</span>
</button>
<button
onClick={logout}
title="退出登录"
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
>
<LogoutIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}