From 64021fe42807a2d0960ae527a09b8c29b3a4da97 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 6 Sep 2026 15:58:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E4=B8=8A"=E9=82=80=E8=AF=B7=E5=A5=BD?= =?UTF-8?q?=E5=8F=8B=E5=85=A5=E7=BE=A4"=E5=8A=9F=E8=83=BD=EF=BC=8CPOST=20/?= =?UTF-8?q?groups/{topic}/invitations=20=E7=9C=9F=E5=AE=9E=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E4=BD=86=E5=89=8D=E7=AB=AF=E9=9B=B6=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: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 --- src/views/group-management-view.jsx | 78 +++++++++++++++++++++++++++-- src/views/tinode-web.jsx | 1 + 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/views/group-management-view.jsx b/src/views/group-management-view.jsx index 01616c2c..f6557e76 100644 --- a/src/views/group-management-view.jsx +++ b/src/views/group-management-view.jsx @@ -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 => )} - @@ -848,6 +853,72 @@ function MemberPicker({type, topic, onToast}) { ; } +// 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
+
+ +
+ {friends === null || memberIds === null ?

{loadError ? '好友列表加载失败' : '加载中…'}

: + groups.length === 0 ?

{friends.length ? '好友都已在群里了' : '暂无可邀请的好友'}

: + groups.map(([letter, list]) => +
{letter}
+ {list.map(item => )} +
)} +
+ setToast('')} /> +
; +} + 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 = ; else if (view === 'group-admin-add') content = ; else if (view === 'group-mute-add') content = ; + else if (view === 'group-invite-friends') content = ; else if (view === 'group-muted') content = ; else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = ; else content = ; diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index 643d5680..72e9a001 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -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',