群成员格子改用头像外角标 ⊖ 移出,替换上一版的行内文字按钮

上一版把 ➖ 加在了"查看全部"弹层的成员行文字按钮里,用户纠正:应该是紧凑头像格子
(群聊设置面板里"查看全部"之前那一屏)里,像"+邀请"那样在头像外侧显示一个独立的
角标,而不是列表行内的文字。canRemoveMember 挪到 lib/api/groups.ts 共享,避免
两处判断逻辑分叉。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-24 17:19:45 +08:00
co-authored by Claude Sonnet 5
parent 2d589cbe6b
commit 05ee23aefa
3 changed files with 61 additions and 10 deletions
@@ -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 (
<button
type="button"
disabled={pending}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label="移出群聊"
className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger-text text-white shadow-sm disabled:opacity-60 md:h-[18px] md:w-[18px]"
>
<Icon name="remove" className="text-[10px]" />
</button>
);
}
/**
* 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"
/>
<RoleCorner role={m.role} />
{canRemoveMember(myUserId, myRole || 'member', m) ? (
<RemoveBadge
pending={removeMutation.isPending && removeMutation.variables === m.user_id}
onRemove={async () => {
const label = m.display_name || m.user_id;
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
removeMutation.mutate(m.user_id);
}
}}
/>
) : null}
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
</div>
))}
+1 -10
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, 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<InviteOutcome, string> = {
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();
+12
View File
@@ -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;