Files
im_webapp/web/src/features/groups/GroupAdminModal.tsx
T
Your NameandClaude Sonnet 5 37d08e8989 feat(web): 用符合系统风格的组件替换全部 window.alert/confirm
全项目搜出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>
2026-09-11 00:08:15 +08:00

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>
);
}