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;