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
+2
View File
@@ -4,6 +4,7 @@ import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppShell } from '@/app/AppShell';
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
import { RequireAuth } from '@/app/RequireAuth';
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
import { LoginPage } from '@/features/auth/LoginPage';
@@ -104,6 +105,7 @@ export default function App() {
tenant" confirmation), not just the logged-in app tree. */}
<ConfirmDialogRoot />
<GlobalToastRoot />
<KickedNoticeRoot />
</QueryClientProvider>
);
}
+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>
);
}
+42 -1
View File
@@ -7,7 +7,7 @@
// silently stomp on each other. This module claims all the SDK's callback slots exactly once and
// fans them out to any number of subscribers via its own Set, which is what hooks.ts's
// useSyncExternalStore-based hooks actually subscribe to.
import { Tinode, type LoginCtrl } from 'tinode-sdk';
import { Tinode, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk';
import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
import { isTenantMismatch } from '@/lib/tinode/errors';
@@ -109,6 +109,32 @@ if (typeof window !== 'undefined') {
window.addEventListener('offline', () => setReconnectState({ networkOnline: false }));
}
/**
* 强制下线通知与顶号互踢-后端接口文档.md §2: an account-level eviction is a `{ctrl}` with
* `code:205, text:"evicted"` and NO `topic` (a topic-scoped one -- e.g. plain removal from a group --
* carries a topic name instead and is left to whatever already handles that; not this module's
* concern). `reason` drives which notice text KickedNoticeRoot shows.
*/
let kickedInfo: { reason: string | undefined } | null = null;
const kickedListeners = new Set<() => void>();
export function getKickedInfo(): { reason: string | undefined } | null {
return kickedInfo;
}
export function subscribeKicked(callback: () => void): () => void {
kickedListeners.add(callback);
return () => kickedListeners.delete(callback);
}
/** KickedNoticeRoot calls this once the user has acknowledged the notice, right before navigating
* to /login -- clears the flag so a later fresh login doesn't leave a stale notice around to
* reappear (e.g. StrictMode remount, or the user landing back on some cached page via history). */
export function clearKickedInfo() {
kickedInfo = null;
kickedListeners.forEach((cb) => cb());
}
/**
* The server extends the stock {hi} packet with a `tenant` field the vendored JS SDK has no API for
* (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). The packet builder is private,
@@ -205,6 +231,21 @@ function createTinode(): Tinode {
if (next <= 0) clearCountdown();
}, 1000);
};
t.onCtrlMessage = (ctrl: TinodeCtrl) => {
if (ctrl.code !== 205 || ctrl.text !== 'evicted' || ctrl.topic) return;
// Tear down the session THE MOMENT eviction is detected, not when the user dismisses the
// notice -- the doc requires reconnect to stop immediately, and resetTinode()'s disconnect()
// sets the SDK's own #boffClosed flag before this handler returns, which is what actually
// prevents the backoff timer from ever scheduling another attempt (confirmed by reading the
// real bundle's Connection#init_ws -- disconnect() is the only thing that does this; there is
// no separate "stop autoreconnect" method to call instead). Only the user-facing navigation to
// /login is deferred, so they get a chance to actually read why.
sessionStorage.removeItem('im-session-token');
useSessionStore.getState().setMyUserId(null);
resetTinode();
kickedInfo = { reason: typeof ctrl.params?.reason === 'string' ? ctrl.params.reason : undefined };
kickedListeners.forEach((cb) => cb());
};
return t;
}
+33
View File
@@ -87,3 +87,36 @@ export function validateCredentialsMessage(params: ValidateCredentialsParams | u
const reason = params?.reason;
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
}
/** 强制下线通知与顶号互踢-后端接口文档.md §2.4's own worked copy, verbatim -- not our own wording,
* so this stays a straight lookup rather than something to rephrase later without checking the doc
* again. `admin_user_evict`/`admin_device_kick` intentionally share one entry (doc's own table
* merges them into a single row). */
const KICKED_REASON_TEXT_CN: Record<string, { title: string; message: string }> = {
replaced_by_new_login: {
title: '账号已在其他设备登录',
message: '您的账号已在另一台设备上登录,当前设备已退出。如非本人操作,请及时修改密码。',
},
admin_user_evict: {
title: '账号已被管理员下线',
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
},
admin_device_kick: {
title: '账号已被管理员下线',
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
},
user_deleted: {
title: '账号已注销',
message: '您的账号已注销,当前会话已退出。',
},
user_state_changed: {
title: '账号已被限制登录',
message: '您的账号已被管理员限制登录,如有疑问请联系管理员。',
},
};
const KICKED_REASON_FALLBACK = { title: '账号已被强制下线', message: '当前会话已被终止,请重新登录。' };
export function kickedNoticeText(reason: string | undefined): { title: string; message: string } {
return (reason && KICKED_REASON_TEXT_CN[reason]) || KICKED_REASON_FALLBACK;
}
+16 -1
View File
@@ -2,7 +2,16 @@ import { useCallback, useSyncExternalStore } from 'react';
import type { Contact, StoredMessage } from 'tinode-sdk';
import { getConnectionStatus, getReconnectState, getTinode, subscribeConnectionStatus, subscribeReconnectState, type ReconnectState } from './connection';
import {
getConnectionStatus,
getKickedInfo,
getReconnectState,
getTinode,
subscribeConnectionStatus,
subscribeKicked,
subscribeReconnectState,
type ReconnectState,
} from './connection';
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics';
/** The current WS connection status, reactive via useSyncExternalStore. */
@@ -15,6 +24,12 @@ export function useReconnectState(): ReconnectState {
return useSyncExternalStore(subscribeReconnectState, getReconnectState);
}
/** Non-null once the server has forcibly evicted this session (顶号/管理端踢出/封禁/删号/重置密码 --
* see 强制下线通知与顶号互踢-后端接口文档.md §2); KickedNoticeRoot is the sole consumer. */
export function useKickedInfo() {
return useSyncExternalStore(subscribeKicked, getKickedInfo);
}
/** The singleton Tinode client instance (stable reference, not itself reactive). */
export function useTinode() {
return getTinode();
+21
View File
@@ -348,6 +348,27 @@ declare module 'tinode-sdk' {
* has stopped entirely (e.g. an explicit disconnect).
*/
onAutoreconnectIteration?: (timeoutMs: number, promise?: Promise<unknown>) => void;
/**
* Fires for every incoming `{ctrl}` packet, account-level ones included (verified
* tinode.dev.js:6564-6566: `if (this.onCtrlMessage) this.onCtrlMessage(pkt.ctrl)`, unconditional,
* runs before the SDK's own internal `code===205` handling). This is the ONLY hook the actual
* installed SDK (0.25.3) offers for a server-forced eviction (`code:205, text:"evicted"`) --
* there is no `onKick` callback in this version despite 强制下线通知与顶号互踢-后端接口文档.md's
* §2.4 claiming the SDK auto-stops reconnect and calls one; read the real bundle to confirm
* before relying on that doc's description of client-side behavior again.
*/
onCtrlMessage?: (ctrl: TinodeCtrl) => void;
}
export interface TinodeCtrl {
code: number;
text: string;
/** Empty/absent for an account-level `{ctrl}` (e.g. forced eviction); a topic-scoped event
* (e.g. a plain "removed from this group") carries the topic name here instead. */
topic?: string;
params?: Record<string, unknown>;
ts?: string;
id?: string;
}
export const Drafty: {