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:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+490
View File
@@ -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>
);
}