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:
co-authored by
Claude Sonnet 5
parent
ab96ae1fee
commit
8f4b0de636
@@ -63,8 +63,21 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
|
||||
setError('');
|
||||
let result: Awaited<ReturnType<typeof createGroup>>;
|
||||
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({
|
||||
name: name.trim(),
|
||||
name: finalName,
|
||||
member_user_ids: [...selected.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getAllGroupMembers,
|
||||
getGroup,
|
||||
getGroupGovernance,
|
||||
getGroupMutes,
|
||||
getAllGroupMutes,
|
||||
getGroupRoleLimits,
|
||||
getGroupSummary,
|
||||
muteGroupMembers,
|
||||
@@ -106,7 +106,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
|
||||
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({
|
||||
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
|
||||
@@ -133,7 +133,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
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')} />;
|
||||
|
||||
return (
|
||||
@@ -153,7 +153,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
</div>
|
||||
<Toggle
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
@@ -164,7 +164,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
</div>
|
||||
<Toggle
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
@@ -177,7 +177,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
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 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: '仅好友' },
|
||||
]}
|
||||
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>
|
||||
{/* `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: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_invite: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_invite_editable === false}
|
||||
/>
|
||||
</div>
|
||||
{/* 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">
|
||||
<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>
|
||||
<span className="text-xs">{mutesQuery.data?.length ?? 0} 人</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
@@ -384,10 +384,20 @@ function AdminsSubView({
|
||||
</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>
|
||||
{(() => {
|
||||
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]" />
|
||||
{atLimit ? '已达管理员人数上限' : '添加管理员'}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
|
||||
@@ -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
|
||||
// GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default.
|
||||
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
|
||||
// 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 () => {
|
||||
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 = () => {
|
||||
@@ -411,7 +420,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
{openModal === 'announcement' ? (
|
||||
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(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 === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||||
</aside>
|
||||
|
||||
@@ -34,6 +34,11 @@ interface GroupFilesModalProps {
|
||||
* owner/admin has no way to remove another member's upload from this UI even though the server
|
||||
* would accept the request. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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 myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -139,14 +144,19 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in
|
||||
the neutral gray this used to be. */}
|
||||
<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' : ''}`}
|
||||
>
|
||||
<Icon name={uploading ? 'hourglass_empty' : 'upload'} className="text-[18px]" />
|
||||
上传
|
||||
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
|
||||
</label>
|
||||
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
|
||||
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' : ''}`}
|
||||
>
|
||||
<Icon name={uploading ? 'hourglass_empty' : 'upload'} className="text-[18px]" />
|
||||
上传
|
||||
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
|
||||
</label>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
|
||||
@@ -252,6 +252,14 @@ export function getGroupMutes(topic: string, 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) {
|
||||
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user