From 05ee23aefaf3c2cad75df9550b8d9438c4d219a9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 17:19:45 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BE=A4=E6=88=90=E5=91=98=E6=A0=BC=E5=AD=90?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=A4=B4=E5=83=8F=E5=A4=96=E8=A7=92=E6=A0=87?= =?UTF-8?q?=20=E2=8A=96=20=E7=A7=BB=E5=87=BA=EF=BC=8C=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E7=89=88=E7=9A=84=E8=A1=8C=E5=86=85=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把 ➖ 加在了"查看全部"弹层的成员行文字按钮里,用户纠正:应该是紧凑头像格子 (群聊设置面板里"查看全部"之前那一屏)里,像"+邀请"那样在头像外侧显示一个独立的 角标,而不是列表行内的文字。canRemoveMember 挪到 lib/api/groups.ts 共享,避免 两处判断逻辑分叉。 Co-Authored-By: Claude Sonnet 5 --- web/src/features/groups/GroupDetailColumn.tsx | 48 +++++++++++++++++++ web/src/features/groups/GroupMembersModal.tsx | 11 +---- web/src/lib/api/groups.ts | 12 +++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx index ce10cf84..92c7bbe9 100644 --- a/web/src/features/groups/GroupDetailColumn.tsx +++ b/web/src/features/groups/GroupDetailColumn.tsx @@ -12,6 +12,7 @@ import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal import { GroupFilesModal } from '@/features/groups/GroupFilesModal'; import { GroupMembersModal } from '@/features/groups/GroupMembersModal'; import { + canRemoveMember, disbandGroup, getGroup, getMyGroupProfile, @@ -19,6 +20,7 @@ import { getGroupSettings, getGroupSummary, leaveGroup, + removeGroupMember, updateGroup, updateGroupMemberSettings, updateMyGroupNickname, @@ -71,6 +73,28 @@ function RoleCorner({ role }: { role: GroupMember['role'] }) { return null; } +/** Corner "-" badge for a removable member's avatar in the compact grid -- the "outside the avatar, + * like the +邀请 tile" treatment (explicit user correction: a text "移出" link inside a member ROW is + * the full-list modal's job, not this grid's). Opposite corner from RoleCorner (top-right, not + * top-left) because both can legitimately apply to the same avatar at once -- an owner removing an + * admin still needs the "管" tag AND this badge on one avatar. */ +function RemoveBadge({ pending, onRemove }: { pending: boolean; onRemove: () => void }) { + return ( + + ); +} + /** * README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx * (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts, @@ -183,6 +207,19 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: onError: (err) => showError(err, '群名保存失败'), }); + // Drives the corner "-" badge on the compact member grid below -- same removeGroupMember call and + // same invalidation keys as GroupMembersModal's own remove button (canRemoveMember is now shared + // from lib/api/groups.ts so the two surfaces can't drift on who's eligible to remove whom). + const removeMutation = useMutation({ + mutationFn: (userId: string) => removeGroupMember(topicId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['group-members', topicId] }); + queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] }); + queryClient.invalidateQueries({ queryKey: ['group', topicId] }); + }, + onError: (err) => showError(err, '移出失败,请重试'), + }); + // Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting // on the async native/`group.dissolved` gone-signal that normally follows a group actually going // away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale @@ -372,6 +409,17 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10" /> + {canRemoveMember(myUserId, myRole || 'member', m) ? ( + { + const label = m.display_name || m.user_id; + if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) { + removeMutation.mutate(m.user_id); + } + }} + /> + ) : null} {m.display_name || m.user_id} ))} diff --git a/web/src/features/groups/GroupMembersModal.tsx b/web/src/features/groups/GroupMembersModal.tsx index 6ac2f80f..c0e86aa5 100644 --- a/web/src/features/groups/GroupMembersModal.tsx +++ b/web/src/features/groups/GroupMembersModal.tsx @@ -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, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups'; +import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; import { requestConfirm } from '@/stores/confirmDialog'; @@ -64,15 +64,6 @@ const OUTCOME_LABEL: Record = { failed: '邀请失败', }; -/** Mirrors server/im_http.go's canRemoveIMGroupMember exactly: can't remove yourself or the owner; - * removing a plain member needs admin-or-better, removing an admin needs the owner specifically. */ -function canRemoveMember(myUserId: string | null, myRole: GroupRole, member: { user_id: string; role: GroupRole }): boolean { - if (!myUserId || member.user_id === myUserId) return false; - if (myRole === 'member') return false; - if (member.role === 'owner') return false; - if (member.role === 'admin' && myRole !== 'owner') return false; - return true; -} export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initialView = 'members', onClose }: GroupMembersModalProps) { const queryClient = useQueryClient(); diff --git a/web/src/lib/api/groups.ts b/web/src/lib/api/groups.ts index 04d8e4f7..4c624270 100644 --- a/web/src/lib/api/groups.ts +++ b/web/src/lib/api/groups.ts @@ -213,6 +213,18 @@ export function removeGroupMember(topic: string, userId: string) { return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`); } +/** Mirrors server/im_http.go's canRemoveIMGroupMember exactly: can't remove yourself or the owner; + * removing a plain member needs admin-or-better, removing an admin needs the owner specifically. + * Shared by every surface that renders a per-member remove affordance (GroupMembersModal's full + * list, GroupDetailColumn's compact avatar grid) so the rule can't drift between the two. */ +export function canRemoveMember(myUserId: string | null, myRole: GroupRole, member: { user_id: string; role: GroupRole }): boolean { + if (!myUserId || member.user_id === myUserId) return false; + if (myRole === 'member') return false; + if (member.role === 'owner') return false; + if (member.role === 'admin' && myRole !== 'owner') return false; + return true; +} + export interface GroupMemberProfile { topic: string; user_id: string;