fix(web): 第9轮审查批次2——群聊域一批问题

- 建群留空群名字直接被后端拒绝:placeholder写的"未填写时默认为成员
  昵称组合"从未真正实现,现在留空时用已选成员昵称拼接生成默认名,
  按MAX_TITLE_LENGTH截断
- 群文件上传按钮不受"群文件上传权限"治理开关约束:新增canUpload
  (和canInvite同一个算法),仅管理员时非管理员看不到上传入口
- 群管理弹层5个治理开关(进群验证/全员禁言/文件上传/私聊权限/邀请
  权限)都没有读取后端返回的对应_editable字段,现在全部接上
- 禁言成员列表只拉第一页:新增getAllGroupMutes复用fetchAllPages,
  和getAllGroupMembers同一个模式,人数多的群不会再静默截断
- 添加管理员没有前端数量上限校验:达到上限时按钮禁用并提示"已达
  管理员人数上限",不再让用户选完人才被后端拒绝
- 清空聊天记录没有错误处理,失败时静默无提示,现在补上

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 12:37:46 +08:00
co-authored by Claude Sonnet 5
parent ab96ae1fee
commit 8f4b0de636
5 changed files with 75 additions and 25 deletions
+14 -1
View File
@@ -63,8 +63,21 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
setError(''); setError('');
let result: Awaited<ReturnType<typeof createGroup>>; let result: Awaited<ReturnType<typeof createGroup>>;
try { try {
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a
// combination of member nicknames when left blank), but `name` has no such fallback
// server-side (unlike `description`, which the protocol doc explicitly allows empty) --
// confirmed live: submitting a blank name gets rejected outright with MALFORMED_REQUEST, not
// silently defaulted. Truncated the same way the input field itself is (MAX_TITLE_LENGTH),
// with an ellipsis when it had to cut names short, so this can't itself produce an
// over-length name the server would also reject.
const trimmedName = name.trim();
let finalName = trimmedName;
if (!finalName) {
const joined = [...selected.values()].join('、');
finalName = joined.length > MAX_TITLE_LENGTH ? `${joined.slice(0, MAX_TITLE_LENGTH - 1)}…` : joined;
}
result = await createGroup({ result = await createGroup({
name: name.trim(), name: finalName,
member_user_ids: [...selected.keys()], member_user_ids: [...selected.keys()],
}); });
} catch (err) { } catch (err) {
+21 -11
View File
@@ -10,7 +10,7 @@ import {
getAllGroupMembers, getAllGroupMembers,
getGroup, getGroup,
getGroupGovernance, getGroupGovernance,
getGroupMutes, getAllGroupMutes,
getGroupRoleLimits, getGroupRoleLimits,
getGroupSummary, getGroupSummary,
muteGroupMembers, muteGroupMembers,
@@ -106,7 +106,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }), queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
enabled: isOwner, enabled: isOwner,
}); });
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getGroupMutes(topicId) }); const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getAllGroupMutes(topicId) });
const govMutation = useMutation({ const govMutation = useMutation({
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch), mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
@@ -133,7 +133,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
onClose={() => setView('main')} onClose={() => setView('main')}
/> />
); );
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data?.items || []} onClose={() => setView('main')} />; if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data || []} onClose={() => setView('main')} />;
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />; if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
return ( return (
@@ -153,7 +153,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
</div> </div>
<Toggle <Toggle
checked={!!govQuery.data?.join_approval} checked={!!govQuery.data?.join_approval}
disabled={!govQuery.data || govMutation.isPending} disabled={!govQuery.data || govMutation.isPending || govQuery.data?.join_approval_editable === false}
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })} onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
/> />
</div> </div>
@@ -164,7 +164,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
</div> </div>
<Toggle <Toggle
checked={!!govQuery.data?.mute_all} checked={!!govQuery.data?.mute_all}
disabled={!govQuery.data || govMutation.isPending} disabled={!govQuery.data || govMutation.isPending || govQuery.data?.mute_all_editable === false}
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })} onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
/> />
</div> </div>
@@ -177,7 +177,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
{ value: 'admin', label: '仅管理员' }, { value: 'admin', label: '仅管理员' },
]} ]}
onChange={(v) => setGov({ member_upload_files: v === 'all' })} onChange={(v) => setGov({ member_upload_files: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending} disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_upload_files_editable === false}
/> />
</div> </div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5"> <div className="flex items-center justify-between border-b border-border-list-item py-3.5">
@@ -189,7 +189,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
{ value: 'friends', label: '仅好友' }, { value: 'friends', label: '仅好友' },
]} ]}
onChange={(v) => setGov({ member_private_chat: v === 'all' })} onChange={(v) => setGov({ member_private_chat: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending} disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_private_chat_editable === false}
/> />
</div> </div>
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a {/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a
@@ -206,7 +206,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
{ value: 'admin', label: '仅管理员' }, { value: 'admin', label: '仅管理员' },
]} ]}
onChange={(v) => setGov({ member_invite: v === 'all' })} onChange={(v) => setGov({ member_invite: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending} disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_invite_editable === false}
/> />
</div> </div>
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this {/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
@@ -226,7 +226,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
<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"> <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="text-text-body">禁言成员</span>
<span className="flex items-center gap-1 text-text-tertiary-2"> <span className="flex items-center gap-1 text-text-tertiary-2">
<span className="text-xs">{mutesQuery.data?.items.length ?? 0} 人</span> <span className="text-xs">{mutesQuery.data?.length ?? 0} 人</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" /> <Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span> </span>
</button> </button>
@@ -384,10 +384,20 @@ function AdminsSubView({
</button> </button>
</div> </div>
))} ))}
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent"> {(() => {
const atLimit = !!maxAdminCount && admins.length >= maxAdminCount;
return (
<button
type="button"
onClick={() => setAdding(true)}
disabled={atLimit}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent disabled:text-text-tertiary-3"
>
<Icon name="add" className="text-[18px]" /> <Icon name="add" className="text-[18px]" />
添加管理员 {atLimit ? '已达管理员人数上限' : '添加管理员'}
</button> </button>
);
})()}
</> </>
)} )}
<Toast message={error} variant="error" onDone={() => setError('')} /> <Toast message={error} variant="error" onDone={() => setError('')} />
+11 -2
View File
@@ -119,6 +119,11 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
// (no extra request needed) -- live-verified this reflects the real value (flipped it via // (no extra request needed) -- live-verified this reflects the real value (flipped it via
// GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default. // GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default.
const canInvite = isAdmin || settingsQuery.data?.governance?.member_invite === true; const canInvite = isAdmin || settingsQuery.data?.governance?.member_invite === true;
// Same gap as `canInvite` right above (same governance toggle pattern, same root cause: the
// "群文件上传权限" toggle in GroupAdminModal writes `member_upload_files` but nothing ever read it
// back) -- `GroupFilesModal`'s upload button was unconditionally visible/clickable regardless of
// this setting.
const canUpload = isAdmin || settingsQuery.data?.governance?.member_upload_files === true;
// No `version` in the request -- see updateGroupMemberSettings's own doc comment for why sending // No `version` in the request -- see updateGroupMemberSettings's own doc comment for why sending
// one 400s every single call (this was the actual root cause of "member-settings always 400", // one 400s every single call (this was the actual root cause of "member-settings always 400",
@@ -159,7 +164,11 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const clearHistory = async () => { const clearHistory = async () => {
if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return; if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return;
tinode.getTopic(topicId).delMessagesAll(false); try {
await tinode.getTopic(topicId).delMessagesAll(false);
} catch (err) {
showError(err, '清空聊天记录失败,请重试');
}
}; };
const reportAbuse = () => { const reportAbuse = () => {
@@ -411,7 +420,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
{openModal === 'announcement' ? ( {openModal === 'announcement' ? (
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} /> <GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
) : null} ) : null}
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} onClose={() => setOpenModal(null)} /> : null} {openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} canUpload={canUpload} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null} {openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null} {openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
</aside> </aside>
+12 -2
View File
@@ -34,6 +34,11 @@ interface GroupFilesModalProps {
* owner/admin has no way to remove another member's upload from this UI even though the server * owner/admin has no way to remove another member's upload from this UI even though the server
* would accept the request. */ * would accept the request. */
isAdmin: boolean; isAdmin: boolean;
/** `governance.member_upload_files` (所有成员/仅管理员), computed by the caller alongside
* `isAdmin` the exact same way `GroupDetailColumn`'s own `canInvite` already is -- without this,
* the toggle in GroupAdminModal had zero effect on this button, the same class of bug
* `canInvite` was already fixed for. */
canUpload: boolean;
onClose: () => void; onClose: () => void;
} }
@@ -60,7 +65,7 @@ function fileTypeFromMime(mime: string, name: string): string {
* more fields than the protocol doc's own minimal example (see groups.ts's header comment), ported * more fields than the protocol doc's own minimal example (see groups.ts's header comment), ported
* from the old app's real, working upload flow rather than guessed from the doc. * from the old app's real, working upload flow rather than guessed from the doc.
*/ */
export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalProps) { export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupFilesModalProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId); const myUserId = useSessionStore((s) => s.myUserId);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
@@ -139,7 +144,11 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
<strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong> <strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in {/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in
the neutral gray this used to be. */} the neutral gray this used to be. Hidden (not just disabled) when governance says
only admins may upload and this viewer isn't one -- there's no "ask an admin" action
a plain member could take from here, so a visible-but-blocked button would only
invite a confusing click. */}
{canUpload ? (
<label <label
className={`flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-accent hover:bg-surface-rail ${uploading ? 'pointer-events-none opacity-50' : ''}`} className={`flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-accent hover:bg-surface-rail ${uploading ? 'pointer-events-none opacity-50' : ''}`}
> >
@@ -147,6 +156,7 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
上传 上传
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" /> <input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
</label> </label>
) : null}
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4"> <button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" /> <Icon name="close" />
</button> </button>
+8
View File
@@ -252,6 +252,14 @@ export function getGroupMutes(topic: string, cursor = '') {
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`); return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
} }
/** `getGroupMutes` alone only ever reads the first page -- unlike `getAllGroupMembers`'s own
* `fetchAllPages` wrapper right above, GroupAdminModal's mutes view called the single-page
* function directly, silently truncating both the list and its own header count once a group has
* enough muted members to span more than one page. */
export function getAllGroupMutes(topic: string) {
return fetchAllPages((cursor) => getGroupMutes(topic, cursor));
}
export function unmuteGroupMember(topic: string, userId: string) { export function unmuteGroupMember(topic: string, userId: string) {
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`); return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
} }