修复禅道Bug清单中的前端问题:好友申请source校验、群解散二次验证缺失、

群解散事件未处理、错误码翻译缺失、恢复登录页误导性提示

- 好友申请首次必失败30000(P0):AddFriendModal把手机号搜索单独标记为
  source:"phone"上报,但后端serveIMFriendRequestCreate的source校验只认
  search/qr/group,命中default分支返回"invalid friend request source"。
  改为手机号搜索也统一上报source:"search"(后端本就不区分具体搜索渠道),
  用两个全新、此前无任何关系的测试账号验证首次申请200成功
- 解散群聊需要短信二次验证但完整流程从未实现(P0):新增
  SecurityChallengeModal通用二次验证弹层(POST /security/challenges→
  验证码→verify→action_token)+ requestActionToken()(stores/
  securityChallenge.ts,与requestConfirm同一模式),接入解散群聊和转让
  群主(后者此前也因缺X-Action-Token必然404/400)。同时发现并修复
  imDelete对无body请求不显式传空对象体,导致携带Content-Type:
  application/json但请求体真正为空时后端JSON解码失败返回400——实测确认
  两个问题都解决后DELETE返回200
- 群聊被解散但当前正查看该群的用户界面不会跳转(P1):新增
  topics.ts的subscribeTopicGone()监听SDK自身的onDeleteTopic回调,
  ConversationView据此在当前会话被解散时弹出提示并跳转回消息列表
- P2P会话发起方标题栏显示原始topic id、对方明明在线却显示离线:确认
  为上一轮已修复的isCommType()根因问题的同一症状,本轮实测(新建从未
  聊过天的账号对)已不再复现;群内消息发送者显示原始uid的关联描述同样
  实测未复现
- 好友关系冲突(已是好友/申请正在处理中)报错英文原文透出:翻译表补充
  FRIEND_REQUEST_CONFLICT
- 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会
  立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正
  超时后才显示该链接,与其上方的提示文案保持一致的显示条件
This commit is contained in:
Your Name
2026-09-16 15:42:41 +08:00
parent ff69e4548c
commit 8013c1675f
14 changed files with 313 additions and 30 deletions
+14 -7
View File
@@ -47,7 +47,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
// Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real
// provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a
// dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable.
const [selectedSource, setSelectedSource] = useState<'search' | 'phone' | 'qr'>('search');
const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [sent, setSent] = useState(false);
@@ -227,12 +227,19 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
type="button"
disabled={!selectable}
onClick={() => {
// `searchType` (already computed above from the query itself) is the real
// signal for which channel actually found this person -- NewFriendsView's
// own SOURCE_LABEL table already has a `phone` entry ("通过手机号搜索")
// that this path never reached because every search result was tagged
// 'search' regardless of how it was found.
setSelectedSource(searchType === 'phone' ? 'phone' : 'search');
// Real, live-confirmed backend validation (im_http.go's
// serveIMFriendRequestCreate): the `source` field's switch only recognizes
// `search`/`qr`/`group` -- there is no `phone` case, so it falls to
// `default` and 400s with "invalid friend request source". A prior pass
// here tried to report phone-originated searches as their own `phone`
// source (for NewFriendsView's SOURCE_LABEL table to show "通过手机号搜索"),
// which broke every phone-search-based friend request outright -- reproduced
// live with two brand-new accounts before this fix (first-ever request,
// never a retry/idempotency issue as it first looked). Phone and account
// search both go through the same `search` channel from the backend's own
// point of view; there is no server-side data to recover that distinction
// from, so `SOURCE_LABEL`'s dead `phone` entry is removed alongside this.
setSelectedSource('search');
setSelected(item);
}}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
+6 -1
View File
@@ -11,7 +11,12 @@ import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type Frien
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' };
// `phone` deliberately excluded -- the backend's own `source` validation (im_http.go's
// serveIMFriendRequestCreate) only accepts `search`/`qr`/`group`; a phone-number search reports the
// same `search` value an account-name search does, since the server has no separate channel for it
// (see AddFriendModal.tsx's own selection handler for the live-confirmed 400 this caused when it
// tried to report `phone` as its own source instead).
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' };
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
+7 -4
View File
@@ -24,6 +24,7 @@ import {
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
interface GroupAdminModalProps {
@@ -469,7 +470,8 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
const queryClient = useQueryClient();
const [error, setError] = useState('');
const transferMutation = useMutation({
mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1),
mutationFn: ({ userId, actionToken }: { userId: string; actionToken: string }) =>
transferGroupOwner(topicId, userId, groupVersion ?? 1, actionToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
@@ -490,9 +492,10 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
cacheKey="eligible-owner"
opts={{ eligibleRole: 'owner' }}
onPick={async (m) => {
if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) {
transferMutation.mutate(m.user_id);
}
if (!(await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true }))) return;
const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
if (!actionToken) return;
transferMutation.mutate({ userId: m.user_id, actionToken });
}}
emptyLabel="没有可转让的成员"
disabled={transferMutation.isPending}
@@ -28,6 +28,7 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
interface GroupDetailColumnProps {
@@ -157,7 +158,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
});
const disbandMutation = useMutation({
mutationFn: () => disbandGroup(topicId),
mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
onSuccess: () => navigate('/messages'),
onError: (err) => showError(err, '解散群聊失败'),
});
@@ -178,7 +179,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const startLeaveOrDisband = async () => {
if (isOwner) {
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
disbandMutation.mutate();
const actionToken = await requestActionToken({
scene: 'group_dissolve',
actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`,
});
if (!actionToken) return;
disbandMutation.mutate(actionToken);
} else {
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
leaveMutation.mutate();
+16 -2
View File
@@ -29,10 +29,10 @@ import { buildMentionedContent, detectMentionTrigger, type PendingMention } from
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
import { closeConversation, ensureTopicAttached, openConversation, subscribeTopicGone } from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notImplemented } from '@/stores/globalToast';
import { notify, notImplemented } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
@@ -165,6 +165,20 @@ export function ConversationView() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId]);
// Someone else dissolving the group currently open here (or this account being removed from it)
// fires the SDK's `onDeleteTopic`, not `onData`/`onPres` -- those only invalidate this screen's
// cached snapshots for re-render, they don't tell it the topic itself is gone. Left unhandled, the
// member who happened to be looking at that conversation just keeps staring at a now-defunct screen
// (an actual reported bug: staying on the dissolved group's view with no way back except a manual
// nav). `notify()` (the global toast, not this screen's own `errorToast`) survives the navigate.
useEffect(() => {
return subscribeTopicGone((goneTopicName) => {
if (goneTopicName !== topicId) return;
notify('该群聊已解散');
navigate('/messages', { replace: true });
});
}, [topicId, navigate]);
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
// `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
// PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message