补上"邀请好友入群"功能,POST /groups/{topic}/invitations 真实可用但前端零调用点

现象:docs/images/内容沟通对话3-开启进群验证与拉人状态.png 里"你邀请时间旅行者、
李惠惠加入群聊"这条系统事件证明产品设计里确实有"从我的好友里直接选人邀请进群"这条
路径;im-api.js 的 inviteGroupMembers()(POST /groups/{topic}/invitations)也早就
封装好,服务端接口本身可用(实测确认:请求成功返回 pending 状态的邀请记录,对方会在
跟我的会话里收到"邀请你加入群聊。立即加入"卡片)。但全仓库检索这个函数零调用点——
群聊设置页成员格的"添加"格子原来点了只弹一句"请通过转发/分享群二维码邀请成员",把
这条真实存在的路径堵成了唯一路径(二维码分享走的是另一条"申请入群待审批"的路,跟
"邀请"语义不同,不能互相替代)。

修复:新增 InviteFriends 组件(复用 MemberPicker 同款字母分组多选布局),数据源换成
好友列表并按当前群成员名单过滤掉已在群里的人,选中后调用 inviteGroupMembers;
GroupSettings 成员格的"添加"改为跳转到这个新页面。

顺带修复一处会导致这个新路由完全打不开的既有陷阵:tinode-web.jsx 的 SIDE_PANEL_VIEWS
是一份独立于 group-management-view.jsx 自己 GROUP_MANAGEMENT_VIEWS 的硬编码合法视图
名单(文件里 104-107 行注释解释过这就是历史上"新增路由忘记同步这份名单导致深链被清空"
的同一类坑)——只在后者加了新路由名、没有同步这份名单,会导致 handleHashRoute 判定
"Unknown sidepanel view" 直接不渲染侧栏,新页面点了跟没点一样,一并补上。

验证:真实浏览器操作,两个全新注册账号(好友关系),从群设置页"添加"格子进入,勾选
好友、点完成,对方在跟我的会话里收到真实的"立即加入"邀请卡片,群设置页成员数不变
(邀请只是待确认,尚未入群,符合预期)。邀请确认接受这一步会 500,是已知后端问题
(新增72,AcceptInvitation 里一处表名/字段名写错,见 im_be/server/db/mysql/
tenant_groups.go:448 用了不存在的 subscriptions/Topic/User 而不是 im_subscriptions/
topic/userid),不是本次改动引入。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-06 15:58:24 +08:00
co-authored by Claude Sonnet 5
parent b6adc032a2
commit 64021fe428
2 changed files with 76 additions and 3 deletions
+75 -3
View File
@@ -10,7 +10,7 @@ import {
updateCurrentGroupAnnouncement, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
getGroupMemberProfile, fetchAllPages
getGroupMemberProfile, fetchAllPages, getFriends, inviteGroupMembers
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
@@ -67,7 +67,7 @@ export const GROUP_MANAGEMENT_VIEWS = [
'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed',
'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files',
'group-file-upload'
'group-file-upload', 'group-invite-friends'
];
// Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather
@@ -467,7 +467,12 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
<Avatar item={item} icon="person" /><small>{item.group_nickname || item.display_name}</small>
</button>)}
<button type="button" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}>
{/* docs/images/内容沟通对话3-开启进群验证与拉人状态.png 里"你邀请XX、XX加入群聊"证明这条
"从好友里选人直接邀请"的路径是产品设计的一部分,POST /groups/{topic}/invitations
(inviteGroupMembers) 也早就是个真实可用的接口——原来这个格子只弹一句"请通过转发/分享
群二维码邀请成员",把这条路堵死了(二维码分享走的是另一条"申请入群待审批"的路,跟"邀请"
语义不同,不能互相替代)。 */}
<button type="button" onClick={() => navigate('group-invite-friends', topic)}>
<span className="group-member-tile-action"><Icon>add</Icon></span><small>添加</small>
</button>
</div>
@@ -848,6 +853,72 @@ function MemberPicker({type, topic, onToast}) {
</div>;
}
// docs/images/内容沟通对话3-开启进群验证与拉人状态.png:"你邀请时间旅行者、李惠惠加入群聊" 这条系统
//事件证明产品设计里确实有一条"从我的好友里挑人直接邀请进群"的路径,且 `POST /groups/{topic}/invitations`
// (im-api.js 的 inviteGroupMembers) 早就封装好、服务端也确认真实可用——但全仓库检索不到任何调用点,
// GroupSettings 成员格的"添加"格子原来只弹一句"请通过转发/分享群二维码邀请成员",把这条本该走的路
// 堵死成了唯一路径(二维码分享走的是另一条"申请入群待审批"的路,语义并不一样,不能算是同一功能的替代)。
// 复用 MemberPicker 同款字母分组多选布局,数据源换成好友列表并按当前群成员名单过滤掉已在群里的人。
function InviteFriends({topic, onToast}) {
const [friends, setFriends] = React.useState(null);
const [memberIds, setMemberIds] = React.useState(null);
const [selected, setSelected] = React.useState([]);
const [query, setQuery] = React.useState('');
const [toast, setToast] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
fetchAllPages(cursor => getFriends('', cursor)).then(setFriends).catch(() => { onToast('加载失败'); setLoadError(true); });
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => setMemberIds(new Set(items.map(m => m.user_id))))
// 成员表拉取失败不应该拦掉整个邀请流程——退化成"不过滤",最坏情况后端自己会在
// 响应里把已在群里的人标成 already_member(治理开关那份文档已经实测过这个字段)。
.catch(() => setMemberIds(new Set()));
}, [topic]);
const eligible = (friends || []).filter(f => !memberIds || !memberIds.has(f.user_id));
const visible = eligible.filter(m => (m.remark || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
const groups = groupBy(visible, m => alphaInitial(m.remark || m.display_name));
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
const done = () => {
if (!selected.length || busy) return;
setBusy(true);
inviteGroupMembers(topic, selected).then(result => {
const items = (result && result.items) || [];
// 已实测过的两种真实 status:新邀请是 "pending"(对方会在跟我的会话里收到"立即加入"卡片),
// 治理开关关闭邀请权限那份文档里实测过 "already_member"(对方已经在群里,直接被服务端忽略)。
// 未识别的 status 一律计入"失败",宁可少报成功也不要谎报。
const already = items.filter(it => it.status === 'already_member').length;
const sent = items.filter(it => it.status === 'pending').length;
const failed = Math.max(selected.length - sent - already, 0);
const parts = [];
if (sent) parts.push(`已发送 ${sent} 条邀请`);
if (already) parts.push(`${already} 人已在群内`);
if (failed) parts.push(`${failed} 人邀请失败`);
setToast(parts.join(',') || '邀请已发送');
window.setTimeout(() => navigate('group-settings', topic), 1500);
}).catch(err => onToast(err.message || '邀请失败')).finally(() => setBusy(false));
};
return <div className="group-page">
<Header title="邀请好友" back="group-settings" backTopic={topic}
right={`完成${selected.length ? `(${selected.length})` : ''}`} onRight={selected.length ? done : undefined} />
<Search value={query} onChange={setQuery} placeholder="搜索好友" />
<main className="group-member-picker">
{friends === null || memberIds === null ? <p className="group-inline-empty">{loadError ? '好友列表加载失败' : '加载中…'}</p> :
groups.length === 0 ? <p className="group-inline-empty">{friends.length ? '好友都已在群里了' : '暂无可邀请的好友'}</p> :
groups.map(([letter, list]) => <React.Fragment key={letter}>
<div className="group-section-title">{letter}</div>
{list.map(item => <button type="button" key={item.user_id} onClick={() => toggle(item.user_id)}>
<Avatar item={item} icon="person" /><span><strong>{item.remark || item.display_name}</strong></span>
<i className={'group-check' + (selected.includes(item.user_id) ? ' checked' : '')}>
{selected.includes(item.user_id) ? <Icon>check</Icon> : null}
</i>
</button>)}
</React.Fragment>)}
</main>
<CenterToast message={toast} onDone={() => setToast('')} />
</div>;
}
function Muted({topic, onToast}) {
const [items, setItems] = React.useState(() => (topic && readCachedData('group-muted:' + topic)) || null);
// groupGovernancePayload's mute list only ever returns a flat user_id (no display_name field --
@@ -1184,6 +1255,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList topic={topic} onToast={setToast} />;
else if (view === 'group-admin-add') content = <MemberPicker type="admin-add" topic={topic} onToast={setToast} />;
else if (view === 'group-mute-add') content = <MemberPicker type="mute-add" topic={topic} onToast={setToast} />;
else if (view === 'group-invite-friends') content = <InviteFriends topic={topic} onToast={setToast} />;
else if (view === 'group-muted') content = <Muted topic={topic} onToast={setToast} />;
else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = <OwnerSelect topic={topic} onToast={setToast} />;
else content = <GroupFiles topic={topic} tinode={tinode} onToast={setToast} />;
+1
View File
@@ -127,6 +127,7 @@ const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','c
'group-my-nickname','group-members','group-role-message','group-qr','group-governance','group-admins','group-admin-add',
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
'group-invite-friends',
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
'favorites-links','favorites-image','favorites-video','favorites-select','favorite-share','file-download','file-downloading',
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',