fix(web): 第9轮审查批次1——认证/图标CSS/顶号竞态/消息核心一批问题

认证域:
- 找回密码验证码请求存在竞态:请求发出后如果用户改了手机号,旧请求
  返回时仍会用旧手机号的challenge�covers新手机号的输入框,现在按
  发起请求时的手机号快照判断,作废过期响应
- 切换企业码时"退出登录"确认框可被绕过:判断依据只有内存里的
  myUserId,直接刷新在/tenant页会让这个值变回null,导致确认框不弹
  就静默销毁一个其实还有效的会话;现在同时检查sessionStorage里的
  token
- 企业码不存在错误文案缺后半句",请核实后重试",和其它两处文案对齐
- 营业执照上传补上前端体积/类型校验(仿照问题反馈页截图校验的写法),
  不再无条件发一次注定被拒的上传请求
- 找回密码页移动端"重置密码"标题重复渲染两次,桌面版标题补上
  min-[900px]:block hidden
- 企业码输入框补maxLength=64

图标CSS:Material Icons的.im-icon规则漏抄了Google原始样式表的两行
(-webkit-font-feature-settings/-webkit-font-smoothing),补齐让
"1:1 copy"真正成立

顶号踢人 vs 已有401登出的竞态:两条路径会同时清空同一个token,如果
401先触发,用户会被直接甩回登录页看不到"账号已在其他设备登录"的
提示——forceReauth()现在先等一小段时间看kickedInfo是否被顶号流程
设置,设置了就让顶号流程自己的按钮触发跳转,避免抢跑

通话信令健壮性:sendCallRinging/Accept/Hangup底层用的{note}包没有id
字段,连接断开时会同步抛异常而不是走Promise reject——之前hangUp()
在这类异常后直接中断,导致后续的leaveCall()清理和UI收尾全部被跳过。
统一加了try/catch兜底

消息核心:
- 名片消息/通话结果消息(非Drafty的扁平自定义对象)之前可以被引用/
  复制/转发,会把这些对象错误地喂给Drafty的解析函数,产生格式错误
  的内容——现在这两类消息只保留收藏/撤回/删除/多选
- MessagesLayout.tsx自己独立计算的peerName没有对slf(自聊天)做
  isSelf特殊处理,会显示裸topic id"slf"而不是昵称,和已经修过的
  ConversationView头部不一致,现在补齐同样的处理
- 发送失败图标/文案错用了danger-text(撤回删除等破坏性操作的语义
  token),改成语义正确的unread token;录音条文字被连带染红,只保留
  指示点是红色
- 群成员数徽标形状(应为4px圆角矩形不是胶囊)和颜色(应为text-tertiary-4
  不是text-tertiary-3)都和设计稿不符,已修正

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 12:33:23 +08:00
co-authored by Claude Sonnet 5
parent fb1fda689c
commit ab96ae1fee
14 changed files with 118 additions and 33 deletions
+11
View File
@@ -1,5 +1,16 @@
import { imPost, imRequest } from './client';
/**
* docs/IM-后端问题清单.md 问题22:此前 `POST /v1/im/auth/logout` 三个候选路径都404,退出登录/
* 切换企业只能靠本地清理(清token+断连接),服务端并不知道这个token已经失效——同一次复核已确认这个
* 接口现在是真实可用的(同一token连续200,调用这个接口后同一token之后全部401,即真的会被吊销)。
* 调用失败(网络错误/接口本身还有问题)不应该阻塞本地清理这条兜底路径,调用方永远不等待/不因为这次
* 调用失败而跳过后续的本地清理。
*/
export function logout() {
return imPost<void>('/auth/logout');
}
export interface VerificationCodeResponse {
challenge_id: string;
resend_after_seconds: number;
+17 -11
View File
@@ -7,6 +7,7 @@
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
// a real, repeatedly-rediscovered bug in the old codebase).
import { API_KEY } from '@/lib/config';
import { getKickedInfo } from '@/lib/tinode/connection';
import { useSessionStore } from '@/stores/session';
const DEFAULT_API_BASE = '/v1/im';
@@ -29,7 +30,7 @@ export class ImApiError extends Error {
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_UNAVAILABLE: '企业码不存在或已停用,请核实后重试',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
@@ -207,17 +208,22 @@ function forceReauth() {
if (reauthTriggered) return;
sessionStorage.removeItem('im-session-token');
useSessionStore.getState().setMyUserId(null);
// The latch only needs to survive until the hard reload below actually happens -- that reload is
// what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the
// latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background
// request 401-ing while already sitting on `/login` (reachable via an in-SPA `&lt;Navigate&gt;`, which
// never reloads) burned the latch with no reload to reset it -- silently disabling this function
// for the rest of that tab's life, including a genuine expiry after the user logs back in on the
// same SPA instance.
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
reauthTriggered = true;
if (typeof window === 'undefined' || window.location.pathname === '/login') return;
reauthTriggered = true;
// An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by
// the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever
// request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to
// network timing, not something this code controls). Without this, a REST 401 that happens to
// land first bounces straight to /login with zero explanation, silently pre-empting the whole
// point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets
// that WS message (usually already in flight over an open connection, typically faster than a
// fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer
// to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved,
// by far the common case) just navigate ~200ms later than before -- not perceptible.
window.setTimeout(() => {
if (getKickedInfo()) return;
window.location.href = '/login';
}
}, 200);
}
const idem = () => ({ 'Idempotency-Key': requestId() });
+8 -2
View File
@@ -1,7 +1,7 @@
import type { StoredMessage } from 'tinode-sdk';
import type { MessagePolicy } from '@/lib/api/bootstrap';
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's
* own `LOCAL_SEQID`, not exported as a Tinode static -- verified against source, not guessed). */
@@ -33,7 +33,13 @@ export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | nu
*/
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
if (isServerDeleted(msg) || isRecalled(msg)) return [];
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
// 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on
// contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/
// forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an
// object it doesn't recognize, producing empty/malformed output rather than an error a user would
// notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types.
const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward'];
// Defer to the server's own supported_actions list when it's present, so a server-side policy
// change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
// will just 400 when tapped -- the time-window check below still applies on top of this.
+24 -3
View File
@@ -66,14 +66,35 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' |
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
}
/**
* `Topic#videoCall` sends a `{note}` packet, which (unlike `{pub}`) carries no `id` field -- read
* the SDK source: with no id to route a response through, a disconnected socket makes the
* underlying `sendText()` throw SYNCHRONOUSLY rather than reject a promise. None of this module's
* callers in `stores/calls.ts` await or `.catch()` these calls (they're fire-and-forget signals),
* so an uncaught synchronous throw here doesn't just fail silently -- it aborts whatever code was
* running right after the call site. Concretely: `hangUp()`'s 60s ring-timeout callback calls this,
* and if the connection happens to be down at that exact moment (e.g. `resetTinode()` ran because
* the account was evicted mid-call), the exception skips every line after it in that function,
* including the local Agora `leaveCall()` cleanup and the UI's own end-of-call state transition --
* the call window would stay stuck open with a live camera/mic track. These three are the only
* `note`-based signals in the app; wrapping here (once) covers every call site in one place.
*/
function sendNote(topic: Topic, event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record<string, unknown>) {
try {
return topic.videoCall(event, seq, payload);
} catch {
return undefined;
}
}
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
return topic.videoCall('ringing', seq, { callId });
return sendNote(topic, 'ringing', seq, { callId });
}
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
return topic.videoCall('accept', seq, { callId });
return sendNote(topic, 'accept', seq, { callId });
}
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
return sendNote(topic, 'hang-up', seq, reason ? { callId, reason } : { callId });
}