feat(web): 实现强制下线通知与顶号互踢的客户端感知(Ctrl 205 evicted)

按docs/强制下线通知与顶号互踢-后端接口文档.md §2实现。该文档§2.4声称
"SDK侧已内置:收到后关闭autoreconnect并回调onKick(reason, ctrl)"——读
实际安装的tinode-sdk(0.25.3)真实源码确认这个说法不成立:SDK对
code:205/text:"evicted"只有topic级的内部处理(topic._resetSub()),账
号级(topic为空)完全没有特殊逻辑,也没有onKick这个回调。

改为用SDK真实存在、此前项目里完全没用过的onCtrlMessage钩子(每条
{ctrl}包都会触发,含账号级的)在connection.ts里自行判定
"code===205 && text==='evicted' && !topic"。检测到后立即:清空
sessionStorage的token、清空myUserId、调用resetTinode()——其内部的
disconnect()会把SDK自己的#boffClosed标记置真,这是唯一能真正阻止
backoff计时器安排下一次重连的方式(读真实bundle的Connection#init_ws
确认,不存在另外一个"关闭autoreconnect"的方法)。只有面向用户的弹窗
和跳转延后到用户点确定,逻辑上是"会话立刻终止,通知是补充说明",不
是"通知完才终止"。

新增KickedNoticeRoot(挂载在App.tsx顶层,和ConfirmDialogRoot/
GlobalToastRoot同一层级,保证任意页面被踢都能弹出),文案原样照抄文
档§2.4的表格(reason→标题/正文),单一"确定"按钮(不是取消/确定二
选一——已经被踢了,没有"取消"这个选项,给出会误导)。确认后用
window.location.href硬跳转/login,和client.ts的forceReauth()同一个
理由:保证内存里的每一处状态(TanStack Query缓存等)都从头重置,不用
指望每个组件各自正确响应"你已经被登出了"。

topic非空的205(群内移出等topic级事件)不受影响,走原有的SDK内部
处理,不弹这个全局提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 11:13:28 +08:00
co-authored by Claude Sonnet 5
parent c3e8f91364
commit 9adadaa57d
6 changed files with 156 additions and 2 deletions
+42
View File
@@ -0,0 +1,42 @@
import { clearKickedInfo } from '@/lib/tinode/connection';
import { kickedNoticeText } from '@/lib/tinode/errors';
import { useKickedInfo } from '@/lib/tinode/hooks';
/**
* Mounted once at the app root (App.tsx), same placement as ConfirmDialogRoot/GlobalToastRoot --
* an eviction can land on any authenticated page, not just ones that happen to render their own
* connection-state UI. By the time this renders, connection.ts's onCtrlMessage handler has already
* torn down the session (token cleared, Tinode singleton disconnected+reset) -- this component's
* only job is showing the user WHY before sending them to /login, per 强制下线通知与顶号互踢-后端
* 接口文档.md §2.4's "弹出中文提示后跳转登录页".
*
* A single acknowledgment button, not ConfirmDialogRoot's 取消/确定 pair -- there is no "cancel" for
* an eviction that already happened, offering one would be misleading. Hard `window.location.href`
* navigation (not an in-SPA `navigate()`), matching client.ts's forceReauth() for the identical
* reason: every piece of in-memory state (TanStack Query cache, any component's local state) should
* reset from scratch, not rely on each one separately noticing "you're logged out now".
*/
export function KickedNoticeRoot() {
const kicked = useKickedInfo();
if (!kicked) return null;
const { title, message } = kickedNoticeText(kicked.reason);
const acknowledge = () => {
clearKickedInfo();
window.location.href = '/login';
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25">
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal">
<strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong>
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
<div className="mt-5 flex justify-end">
<button type="button" onClick={acknowledge} className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white">
确定
</button>
</div>
</div>
</div>
);
}