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('');
|
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) {
|
||||||
|
|||||||
@@ -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('')} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user