feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -0,0 +1,490 @@
|
||||
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 { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupAdminModalProps {
|
||||
topicId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (v: T) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`px-3 py-1 disabled:opacity-50 ${value === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<SubView>('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<typeof updateGroupGovernance>[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 <JoinRequestsModal topicId={topicId} onClose={() => setView('main')} />;
|
||||
if (view === 'admins')
|
||||
return (
|
||||
<AdminsSubView
|
||||
topicId={topicId}
|
||||
admins={adminsQuery.data || []}
|
||||
maxAdminCount={maxAdminCount}
|
||||
groupVersion={groupQuery.data?.version}
|
||||
onClose={() => setView('main')}
|
||||
/>
|
||||
);
|
||||
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data?.items || []} onClose={() => setView('main')} />;
|
||||
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[80vh] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群管理</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-2 text-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">进群验证</span>
|
||||
<span className="text-xs text-text-tertiary-3">开启后新成员需管理员审核</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.join_approval}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">全员禁言</span>
|
||||
<span className="text-xs text-text-tertiary-3">仅群主和管理员可以发言</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.mute_all}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">群文件上传权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_upload_files ? 'all' : 'admin'}
|
||||
options={[
|
||||
{ value: 'all', label: '所有成员' },
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_upload_files: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">成员私聊权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_private_chat ? 'all' : 'friends'}
|
||||
options={[
|
||||
{ value: 'all', label: '允许' },
|
||||
{ value: 'friends', label: '仅好友' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{/* `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. */}
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">邀请入群权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_invite ? 'all' : 'admin'}
|
||||
options={[
|
||||
{ value: 'all', label: '所有成员' },
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_invite: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
|
||||
entire sub-resource (list included) requires ownership, not just admin. */}
|
||||
{isOwner ? (
|
||||
<button type="button" onClick={() => setView('admins')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">群管理员</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
<span className="text-xs">
|
||||
{adminsQuery.data?.length ?? 0}
|
||||
{maxAdminCount ? `/${maxAdminCount}` : ''}
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setView('mutes')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">禁言成员</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
<span className="text-xs">{mutesQuery.data?.items.length ?? 0} 人</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setView('join-requests')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">进群申请</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{summaryQuery.data?.pending_join_request_count ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{summaryQuery.data.pending_join_request_count}
|
||||
</b>
|
||||
) : null}
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
{isOwner ? (
|
||||
<button type="button" onClick={() => setView('transfer')} className="flex w-full items-center justify-between py-3.5 text-left">
|
||||
<span className="text-text-body">转让群主</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<typeof getAllGroupMembers>[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 <div className="py-6 text-center text-sm text-text-tertiary-4">加载中…</div>;
|
||||
if (candidates.length === 0) return <div className="py-6 text-center text-sm text-text-tertiary-4">{emptyLabel}</div>;
|
||||
return (
|
||||
<>
|
||||
{candidates.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick(m)}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:opacity-50"
|
||||
>
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SubModalShell({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center gap-2 border-b border-border-list-item px-3">
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 flex-none place-items-center text-text-tertiary-4">
|
||||
<Icon name="arrow_back" />
|
||||
</button>
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SubModalShell title={adding ? '添加管理员' : `群管理员 ${countLabel}`} onClose={adding ? () => setAdding(false) : onClose}>
|
||||
{adding ? (
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-admin"
|
||||
opts={{ eligibleRole: 'admin' }}
|
||||
onPick={(m) => addMutation.mutate(m.user_id)}
|
||||
emptyLabel="没有可添加的成员"
|
||||
disabled={addMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{admins.map((a) => (
|
||||
<div key={a.user_id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(a.user_id)}`}>
|
||||
{avatarLetter(a.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{a.display_name || a.user_id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(a.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="text-xs text-danger-text disabled:opacity-50"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
添加管理员
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SubModalShell title={adding ? '选择禁言成员' : `禁言成员 ${muted.length}`} onClose={adding ? () => setAdding(false) : onClose}>
|
||||
{adding ? (
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="mute-eligible"
|
||||
opts={{ muteEligible: true }}
|
||||
onPick={(m) => muteMutation.mutate(m.user_id)}
|
||||
emptyLabel="没有可禁言的成员"
|
||||
disabled={muteMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{muted.map((m) => (
|
||||
<div key={m.user.user_id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user.user_id)}`}>
|
||||
{avatarLetter(m.user.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{m.user.display_name || m.user.user_id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unmuteMutation.mutate(m.user.user_id)}
|
||||
disabled={unmuteMutation.isPending}
|
||||
className="text-xs text-accent disabled:opacity-50"
|
||||
>
|
||||
解除禁言
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
禁言成员
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SubModalShell title="转让群主" onClose={onClose}>
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-owner"
|
||||
opts={{ eligibleRole: 'owner' }}
|
||||
onPick={(m) => {
|
||||
if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id);
|
||||
}}
|
||||
emptyLabel="没有可转让的成员"
|
||||
disabled={transferMutation.isPending}
|
||||
/>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user