import { useState, type ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Icon } from '@/app/Icon'; import { Toast } from '@/app/Toast'; import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal'; import { addGroupAdmins, getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupRoleLimits, getGroupSummary, muteGroupMembers, removeGroupAdmin, transferGroupOwner, unmuteGroupMember, updateGroupGovernance, type GroupMember, type GroupMute, } from '@/lib/api/groups'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; import { requestConfirm } from '@/stores/confirmDialog'; import { useSessionStore } from '@/stores/session'; interface GroupAdminModalProps { topicId: string; onClose: () => void; } function SegmentedControl({ value, options, onChange, disabled, }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void; disabled?: boolean; }) { return (
{options.map((opt) => ( ))}
); } function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) { return ( ); } type SubView = 'main' | 'admins' | 'mutes' | 'join-requests' | 'transfer'; /** README §4.5 (560px): governance toggles/segments + admins/mutes/join-requests/owner-transfer link * rows. `addGroupAdmins`/`removeGroupAdmin`/`transferGroupOwner` all share ONE optimistic-lock * counter with the group profile itself -- confirmed by the doc's own worked example (§6.29): * admins/batch takes version:1 and returns version:2, then owner-transfer takes version:2 and * returns version:3. So the current value always comes from `getGroup`, re-read (via query * invalidation) after every such mutation -- a hardcoded constant would only be correct once. */ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) { const queryClient = useQueryClient(); const myUserId = useSessionStore((s) => s.myUserId); const [view, setView] = useState('main'); const [error, setError] = useState(''); useEscapeKey(onClose); const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) }); // README §4.5: 转让群主仅群主可见 -- `GroupPermissions` has no dedicated `can_transfer_ownership` // field, but `GroupProfile.owner` (the real owner's user id) is already fetched here for the // shared version counter, so comparing it against the session's own id is free, no extra request. const isOwner = !!myUserId && groupQuery.data?.owner === myUserId; const govQuery = useQuery({ queryKey: ['group-governance', topicId], queryFn: () => getGroupGovernance(topicId) }); const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) }); const roleLimitsQuery = useQuery({ queryKey: ['group-role-limits', topicId], queryFn: () => getGroupRoleLimits(topicId) }); // Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupAdmins): the entire // `/admins*` surface -- list included, not just add/remove -- requires OWNER, a plain admin gets // 403 even just listing. `enabled: isOwner` stops a non-owner admin from firing a request that can // only ever fail, and pairs with the `isOwner` gate on the "群管理员" row itself below. const adminsQuery = useQuery({ queryKey: ['group-members', topicId, 'admin'], queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }), enabled: isOwner, }); const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getGroupMutes(topicId) }); const govMutation = useMutation({ mutationFn: (patch: Parameters[1]) => updateGroupGovernance(topicId, patch), onSuccess: (data) => queryClient.setQueryData(['group-governance', topicId], data), onError: (err) => setError(err instanceof Error ? err.message : '设置失败'), }); const setGov = ( patch: Partial<{ join_approval: boolean; mute_all: boolean; member_upload_files: boolean; member_private_chat: boolean; member_invite: boolean }>, ) => { if (!govQuery.data) return; govMutation.mutate({ ...patch, version: govQuery.data.version }); }; const maxAdminCount = roleLimitsQuery.data?.max_admin_count; if (view === 'join-requests') return setView('main')} />; if (view === 'admins') return ( setView('main')} /> ); if (view === 'mutes') return setView('main')} />; if (view === 'transfer') return setView('main')} />; return (
e.stopPropagation()}>
群管理
进群验证 开启后新成员需管理员审核
setGov({ join_approval: !govQuery.data?.join_approval })} />
全员禁言 仅群主和管理员可以发言
setGov({ mute_all: !govQuery.data?.mute_all })} />
群文件上传权限 setGov({ member_upload_files: v === 'all' })} disabled={!govQuery.data || govMutation.isPending} />
成员私聊权限 setGov({ member_private_chat: v === 'all' })} disabled={!govQuery.data || govMutation.isPending} />
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a real, already-read/write-capable field with no row anywhere in 03-group.dc.html's own 群管理弹层 mock (#1b only draws 4 governance rows) -- placed here as a 5th row using the SAME SegmentedControl treatment as 群文件上传权限/成员私聊权限 just above (same modal, same "who is allowed to do X" semantics), rather than inventing a new visual pattern. */}
邀请入群权限 setGov({ member_invite: v === 'all' })} disabled={!govQuery.data || govMutation.isPending} />
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this entire sub-resource (list included) requires ownership, not just admin. */} {isOwner ? ( ) : null} {isOwner ? ( ) : null}
setError('')} />
); } /** Shared candidate list for the admin/mute/owner-transfer pickers. All three server endpoints * follow the same "候选" shape (doc §6.19/6.23/6.25): `GET .../members` with an `eligible_role=` or * `mute_eligible=` filter, whose response items still carry `eligible` for entries the caller can't * act on -- so this filters those out client-side rather than trusting the query param alone to * have excluded them server-side. */ function MemberPickerList({ topicId, cacheKey, opts, onPick, emptyLabel, disabled, }: { topicId: string; cacheKey: string; opts: Parameters[1]; onPick: (m: GroupMember) => void; emptyLabel: string; disabled?: boolean; }) { const membersQuery = useQuery({ queryKey: ['group-members', topicId, cacheKey], queryFn: () => getAllGroupMembers(topicId, opts) }); const candidates = (membersQuery.data || []).filter((m) => m.eligible !== false); if (membersQuery.isLoading) return
加载中…
; if (candidates.length === 0) return
{emptyLabel}
; return ( <> {candidates.map((m) => ( ))} ); } function SubModalShell({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { return (
e.stopPropagation()}>
{title}
{children}
); } function AdminsSubView({ topicId, admins, maxAdminCount, groupVersion, onClose, }: { topicId: string; admins: GroupMember[]; maxAdminCount: number | undefined; groupVersion: number | undefined; onClose: () => void; }) { const queryClient = useQueryClient(); const [adding, setAdding] = useState(false); const [error, setError] = useState(''); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['group-members', topicId] }); // `group-members-preview` is a SEPARATE cache key (GroupDetailColumn.tsx's own 9-grid query) -- // missing it here meant promoting/demoting an admin never refreshed that grid's own "主"/"管" // corner badges until something else happened to invalidate it. queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] }); queryClient.invalidateQueries({ queryKey: ['group', topicId] }); queryClient.invalidateQueries({ queryKey: ['group-role-limits', topicId] }); }; const addMutation = useMutation({ mutationFn: (userId: string) => addGroupAdmins(topicId, [userId], groupVersion ?? 1), onSuccess: () => { invalidate(); setAdding(false); }, onError: (err) => setError(err instanceof Error ? err.message : '添加失败'), }); const removeMutation = useMutation({ mutationFn: (userId: string) => removeGroupAdmin(topicId, userId, groupVersion ?? 1), onSuccess: invalidate, onError: (err) => setError(err instanceof Error ? err.message : '移除失败'), }); const countLabel = maxAdminCount ? `${admins.length}/${maxAdminCount}` : `${admins.length}`; useEscapeKey(onClose); return ( setAdding(false) : onClose}> {adding ? ( addMutation.mutate(m.user_id)} emptyLabel="没有可添加的成员" disabled={addMutation.isPending} /> ) : ( <> {admins.map((a) => (
{avatarLetter(a.display_name)} {a.display_name || a.user_id}
))} )} setError('')} />
); } function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: GroupMute[]; onClose: () => void }) { const queryClient = useQueryClient(); const [adding, setAdding] = useState(false); const [error, setError] = useState(''); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] }); const muteMutation = useMutation({ mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]), onSuccess: () => { invalidate(); setAdding(false); }, onError: (err) => setError(err instanceof Error ? err.message : '禁言失败'), }); const unmuteMutation = useMutation({ mutationFn: (userId: string) => unmuteGroupMember(topicId, userId), onSuccess: invalidate, onError: (err) => setError(err instanceof Error ? err.message : '解除禁言失败'), }); useEscapeKey(onClose); return ( setAdding(false) : onClose}> {adding ? ( muteMutation.mutate(m.user_id)} emptyLabel="没有可禁言的成员" disabled={muteMutation.isPending} /> ) : ( <> {muted.map((m) => (
{avatarLetter(m.user.display_name)} {m.user.display_name || m.user.user_id}
))} )} setError('')} />
); } function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: string; groupVersion: number | undefined; onClose: () => void }) { const queryClient = useQueryClient(); const [error, setError] = useState(''); const transferMutation = useMutation({ mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['group', topicId] }); queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] }); queryClient.invalidateQueries({ queryKey: ['group-members', topicId] }); // Same gap as AdminsSubView's own invalidate() -- the 9-grid "主" badge in GroupDetailColumn // reads this separate cache key, not `group-members`. queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] }); onClose(); }, onError: (err) => setError(err instanceof Error ? err.message : '转让群主失败'), }); useEscapeKey(onClose); return ( { if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) { transferMutation.mutate(m.user_id); } }} emptyLabel="没有可转让的成员" disabled={transferMutation.isPending} /> setError('')} /> ); }