修复邀请入群交互:点击邀请要直接进入邀请,而不是先看成员列表再点第二次

用户实测反馈的真实bug:GroupDetailColumn里那个专门的"+邀请"图标(虚线边框
方块,只有canInvite为真才显示——这个权限门控本身一直是对的)点击后走的是
setOpenModal('members'),和"查看全部成员"是完全同一个入口——弹出的是成员
列表,标题栏里那个真正的"邀请"按钮还要再点一次才能看到可邀请的联系人列表。

GroupMembersModal新增initialView属性,"invite"直接以邀请视图打开(跳过
成员列表这一步),"查看全部"/"查看全部成员"两处不受影响仍然打开成员列表。

同时补全了邀请结果的反馈——原来邀请成功后只做了查询失效,无论是"立即加入"
还是需要对方/群设置审批的"待确认",界面上都没有任何区别于点击前的视觉反馈,
容易让人怀疑是不是点了没反应从而重复点击。读了im_be的serveIMGroupInvitations
源码(不是协议文档的单一示例)确认真实的单人邀请结果有joined/already_member/
pending/pending_approval/failed五种可能,现在每种都有对应的行内文案,
"failed"不计入"已处理"状态,允许原地重试。

实机验证:全新建群后点"+邀请"直接进入邀请视图(标题"邀请成员",不经过成员
列表);邀请一个全新好友后确认成功加入、群成员数正确从2变3、界面正确显示
结果反馈。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 22:26:12 +08:00
co-authored by Claude Sonnet 5
parent 3fab8b08b9
commit 66dad284c7
2 changed files with 68 additions and 12 deletions
+15 -4
View File
@@ -81,7 +81,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const [nicknameEditing, setNicknameEditing] = useState(false);
const [nicknameDraft, setNicknameDraft] = useState('');
const [errorToast, setErrorToast] = useState('');
const [openModal, setOpenModal] = useState<'members' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
// Same long-lived-instance leak this exact pattern already caused twice in DetailColumn.tsx --
@@ -284,7 +284,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
</div>
))}
{canInvite ? (
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
// "invite"`), not the member list -- this dedicated "+邀请" tile landing on the member
// list and requiring a SECOND click on an in-modal "邀请" button was a real, reported
// UX complaint ("点击邀请那个按钮弹出的并不是直接到邀请,而是显示的成员列表,还要再次
// 点击邀请"). "查看全部"/"查看全部成员" right below/above this still correctly open the
// plain member list -- only THIS tile's whole purpose is inviting.
<button type="button" onClick={() => setOpenModal('invite')} className="flex flex-col items-center gap-1">
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
<Icon name="add" className="text-[18px]" />
</span>
@@ -420,8 +426,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
{openModal === 'members' ? (
<GroupMembersModal topicId={topicId} canInvite={canInvite} onClose={() => setOpenModal(null)} />
{openModal === 'members' || openModal === 'invite' ? (
<GroupMembersModal
topicId={topicId}
canInvite={canInvite}
initialView={openModal === 'invite' ? 'invite' : 'members'}
onClose={() => setOpenModal(null)}
/>
) : null}
{openModal === 'announcement' ? (
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
+53 -8
View File
@@ -6,7 +6,7 @@ import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { getAllGroupMembers, inviteGroupMembers, type GroupRole } from '@/lib/api/groups';
import { getAllGroupMembers, inviteGroupMembers, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
@@ -28,6 +28,13 @@ interface GroupMembersModalProps {
* from the same settings query) rather than re-deriving from a bare role here, which is exactly
* what silently made that governance toggle a no-op before this was wired through. */
canInvite: boolean;
/** "invite" opens straight into the invite-candidates view, skipping the member list entirely --
* the dedicated "邀请" entry point (GroupDetailColumn's dashed-border tile) used to route through
* here too but land on the member list regardless, requiring a SECOND click on an in-modal "邀请"
* button to actually reach this view (a real, reported UX complaint: "点击邀请那个按钮弹出的并不是
* 直接到邀请,而是显示的成员列表,还要再次点击邀请"). Defaults to "members" for every OTHER caller
* (查看全部成员 etc.), which should still land on the member list as before. */
initialView?: 'members' | 'invite';
onClose: () => void;
}
@@ -36,11 +43,27 @@ interface GroupMembersModalProps {
* an existing group, not just at creation time). No "移出成员" affordance here: this protocol has
* no documented REST or WS operation for removing a group member at all (grepped the whole
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */
export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersModalProps) {
/** Per-candidate outcome once invited THIS session -- keyed by user_id, separate from `alreadyMember`
* (a person can be successfully invited without becoming a member yet -- see the "pending"/
* "pending_approval" case below). No REST endpoint exists to list a group's outstanding invitations
* (confirmed: grepped the protocol doc), so this can only ever reflect invites sent from THIS modal
* session, not ones sent earlier and never followed up on -- reopening the modal loses this. */
type InviteOutcome = GroupInvitationResult['status'];
const OUTCOME_LABEL: Record<InviteOutcome, string> = {
joined: '已加入',
already_member: '已在群内',
pending: '邀请已发送',
pending_approval: '待对方确认',
failed: '邀请失败',
};
export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onClose }: GroupMembersModalProps) {
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [inviting, setInviting] = useState(false);
const [inviting, setInviting] = useState(initialView === 'invite');
const [error, setError] = useState('');
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
useEscapeKey(onClose);
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
@@ -59,7 +82,17 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
const inviteMutation = useMutation({
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
onSuccess: () => {
onSuccess: (data, userId) => {
// A real, previously-missing gap this fixes: the old version only ever refetched the member
// list and left it at that -- for the common "joined immediately" case that's a silent success
// with no feedback beyond the candidate eventually re-rendering as "已在群内" on its own, and
// for "pending"/"pending_approval" (requires the target's own action, or the group's/their own
// approval setting) there was NO feedback at all -- the row just sat there looking un-invited,
// inviting a confused repeat click. `items[0]` because this call always invites exactly one
// user_id at a time.
const outcome = data.items[0]?.status;
if (outcome) setOutcomeByUser((prev) => ({ ...prev, [userId]: outcome }));
if (outcome === 'failed' && data.items[0]?.reason) setError(data.items[0].reason);
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
@@ -101,22 +134,34 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
.filter((f) => !query.trim() || (f.remark || f.display_name || f.user_id).toLowerCase().includes(query.trim().toLowerCase()))
.map((f) => {
const label = f.remark || f.display_name || f.user_id;
// `alreadyMember` (a real member, known up front) and a THIS-SESSION invite outcome
// are two different reasons a candidate can be non-actionable -- alreadyMember wins if
// both are somehow true (the member-list refetch above should already have cleared the
// "joined" outcome to alreadyMember=true by then anyway).
const outcome = f.alreadyMember ? 'already_member' : outcomeByUser[f.user_id];
// "failed" deliberately does NOT count as settled -- could be a transient network blip,
// and there's no harm in letting the same click retry (a genuinely permanent failure,
// e.g. the target blocked invites, just fails again harmlessly). Every other outcome is
// a real, done state with nothing left to retry.
const settled = !!outcome && outcome !== 'failed';
return (
<button
key={f.user_id}
type="button"
disabled={inviteMutation.isPending || f.alreadyMember}
disabled={inviteMutation.isPending || settled}
onClick={() => inviteMutation.mutate(f.user_id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:hover:bg-transparent ${
f.alreadyMember ? 'opacity-50' : ''
settled ? 'opacity-50' : ''
}`}
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}>
{avatarLetter(label)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{label}</span>
{f.alreadyMember ? (
<span className="flex-none text-xs text-text-tertiary-3">已在群内</span>
{outcome ? (
<span className={`flex-none text-xs ${outcome === 'failed' ? 'text-danger-text' : 'text-text-tertiary-3'}`}>
{OUTCOME_LABEL[outcome]}
</span>
) : (
<Icon name="person_add" className="text-[18px] text-accent" />
)}