全项目搜出15处 window.confirm、9处 window.alert(其中3处是各文件
各自复制的 notImplemented() 辅助函数,文案还都一样)用在退出登录、
删除消息/收藏/好友、清空聊天记录、解散群聊、转让群主、危险链接
二次确认等场景——全是浏览器原生弹窗,样式和整个应用的设计语言完全
脱节。
新增两个全局UI基础设施(Zustand store + 挂载在App.tsx根节点的
渲染组件,参照stores/calls.ts/messageJump.ts已有的"任何组件都能调用"
惯例,不是每个弹窗各自管理一份本地状态):
- stores/confirmDialog.ts + app/ConfirmDialogRoot.tsx:替换
window.confirm。requestConfirm()返回Promise<boolean>,和
window.confirm的返回值语义一致,所以原来"if (!window.confirm(...))
return"这一行改成"if (!(await requestConfirm({message:...})))
return",外层函数标成async,不用大改周围逻辑。危险/不可恢复操作
(删除、解散群聊、注销账号等)传danger:true,确认按钮变红色,
弹窗视觉沿用转发弹窗已有的"描边取消+实心确认"按钮搭配。
- stores/globalToast.ts + app/GlobalToastRoot.tsx:替换window.alert,
复用应用里本来就有、每个页面各自本地状态管理的Toast组件。顺手把
3个文件里各自重复定义、文案完全一样的notImplemented()合并成
一个共享导出。
实机验证了非危险确认(清空缓存,蓝色确认按钮)、危险确认(删除
消息,红色确认按钮)、取消按钮、Escape键取消、全局提示(点"投诉
举报")——均正常工作,且原有页面各自的Toast错误提示不受影响。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
494 lines
22 KiB
TypeScript
494 lines
22 KiB
TypeScript
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<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={async (m) => {
|
|
if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) {
|
|
transferMutation.mutate(m.user_id);
|
|
}
|
|
}}
|
|
emptyLabel="没有可转让的成员"
|
|
disabled={transferMutation.isPending}
|
|
/>
|
|
<Toast message={error} variant="error" onDone={() => setError('')} />
|
|
</SubModalShell>
|
|
);
|
|
}
|