From 02b98cceae88798a3e6cf751c79b0c0cb1ed1836 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 3 Sep 2026 18:40:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=A8=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=B5=B0=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=E4=BA=8C=E6=89=B9=EF=BC=9A=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E3=80=81=E9=80=9A=E8=AE=AF=E5=BD=95=E3=80=81=E7=BE=A4=E8=81=8A?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第1章 - tinode-web: 手输密码登录失败会 navigateTo('') 把用户弹回企业码页,刚输的账号丢了,而 TenantEntry 不渲染 FormAlert,那句错误提示根本看不到。改成只有存储的 token 失效才回登录入口。 - auth-onboarding IdentityCheck: 限流 429 时服务端会连同未过期的 challenge_id 一起返回, 原来只显示错误文案、challengeId 留空,于是 submit 开头的守卫静默吞掉点击——输完验证码点 "确定"毫无反应也无提示。同文件 VerificationButton 早就处理对了,这里是漏抄。 - auth-onboarding Register: 手机号注册路径硬传 invite_code:'',把用户填的邀请码丢了。后端 user.go:175-181 对邀请码的校验和 RecordUse 不区分注册路径,UI 上也摆着这个输入框。 第5章 - 好友列表主副行显示同一串字:后端在有备注时把 display_name 也设成备注(im_http.go:8500-8507), 两个字段同值。加"不同才显示"的守卫。 - 拉黑确认弹窗把设置页当背景渲染并摆出"操作之后"的样子,但背景组件自己的 getUserProfile 回来后又把开关改回未拉黑、"删除好友"红条重新冒出来。加 pinBlocked 挡住这次覆盖。 第6章 - 群列表把群名显示成 topic id、成员数恒为 0。根因在后端:handler 用 store.Users.GetSubs,而 store 自己的注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835), groupListItemPayload 却去读 sub.GetPublic()/GetSubCnt()(应该用 GetTopics)。已记为后端问题; 前端先用本地 Tinode 会话缓存兜底(消息首页一直这么取,是准的)。 - 禁言页每行头像画成群组图标(Avatar 兜底图标默认 'group',没传 icon="person"), 且顺带从成员表带上真实头像。 - 禁言页"以上成员不允许发言"被当成空状态用,语义正好反了——有禁言成员时这句消失、 一个都没有时反而显示。改为列表下方常驻注脚,空态另给文案,并补 .group-inline-note 样式。 - bytesToGB 只输出到 MB,1.4GB 的文件显示成 "1433.6 MB"。 - 群文件"来自"直接显示裸 uploader_id(groupFilePayload 不给昵称),拉成员表交叉引用。 - 群公告从聊天页横幅进入后返回,会把会话一起关掉:navigate 只写 query 参数,path[1] 仍为空, handleHashRoute 就把 topicSelected 清成 null。Header 支持 onBack,显式把 topic 写回 path[1]。 - 群成员点头像打不开非好友资料页:目标页先读 'profile-seed:' 缓存,因为 getUserProfile 对 非好友直接 403。第5章入口一直有 seed,这里漏了。 - 群简介上限 225 超过后端硬限 200(im_http.go:4667),写满必被打回。 Co-Authored-By: Claude Opus 5 --- css/base.css | 9 +++ src/views/auth-onboarding-view.jsx | 20 ++++- src/views/contact-management-view.jsx | 16 +++- src/views/group-management-view.jsx | 102 ++++++++++++++++++++---- src/views/tinode-web.jsx | 7 +- umd/index.dev.js | 110 +++++++++++++++++++++----- umd/index.dev.js.map | 2 +- 7 files changed, 224 insertions(+), 42 deletions(-) diff --git a/css/base.css b/css/base.css index 382d7707..ecd26ee7 100644 --- a/css/base.css +++ b/css/base.css @@ -10126,6 +10126,15 @@ div.toast.show { text-align: center; } +/* 列表下方的常驻灰色注脚(禁言页的"以上成员不允许发言"),区别于上面的空状态文案。 */ +.group-inline-note { + margin: 0; + padding: 0.9rem 1rem 1.4rem; + color: #969da5; + font-size: 0.85rem; + text-align: center; +} + /* Alphabet-group section header inside MemberPicker/OwnerSelect, same treatment as .group-admin-page h2. */ .group-section-title { margin: 0; diff --git a/src/views/auth-onboarding-view.jsx b/src/views/auth-onboarding-view.jsx index be57e2ff..e1fc79bd 100644 --- a/src/views/auth-onboarding-view.jsx +++ b/src/views/auth-onboarding-view.jsx @@ -422,7 +422,11 @@ function Register({errorText, errorLevel, onImRegisterRequest}) { // set a nickname afterwards). const secretAccount = form.username || ('+86' + form.phone); onImRegisterRequest(secretAccount, form.password, form.username || '', - {username: form.username || '', phone: form.phone, invite_code: ''}, + // 邀请码不能在手机号路径上丢掉:后端 user.go:175-181 对邀请码的校验和 RecordUse 都不区分 + // 注册路径(`if inviteCode != ""` 就会校验并记录),而且 user.go:169 的 + // inviteRequired 在短信通道关闭时对手机号注册同样为真。UI 上明明摆着邀请码输入框, + // 原来却硬传空串,用户填了也被静默丢弃,邀请来源统计全丢。 + {username: form.username || '', phone: form.phone, invite_code: form.invite || ''}, {meth: 'tel', val: form.phone, resp: verified.verification_token, params: {challenge_id: challengeId}}); } else { onImRegisterRequest(form.username, form.password, form.username, @@ -546,6 +550,20 @@ function IdentityCheck({authenticated}) { setSeconds(data.resend_after_seconds || 60); } catch (err) { setError(err.reason === 'REALNAME_REQUIRED' ? '需要先完成实名认证并绑定手机号' : (err.message || '获取验证码失败')); + // 限流(429)时服务端会连同还没过期的 challenge_id 和剩余冷却秒数一起返回 + // (im_http.go:6361-6367)。原来这里只把错误文案显示出来就完了,challengeId 一直是空串, + // 于是 submit() 开头的 `if (!challengeId) return` 会静默吞掉点击——按钮看着能点, + // 输完 6 位验证码点"确定"毫无反应,也没有任何提示。同文件的 VerificationButton + // 早就处理对了,这里是漏抄。 + if (err.details && err.details.retry_after_seconds) { + setSeconds(err.details.retry_after_seconds); + } + if (err.details && err.details.challenge_id) { + setChallengeId(err.details.challenge_id); + } + if (err.details && err.details.phone_mask) { + setPhoneMask(err.details.phone_mask); + } } finally { setBusy(false); } diff --git a/src/views/contact-management-view.jsx b/src/views/contact-management-view.jsx index e9528b5e..65c376ca 100644 --- a/src/views/contact-management-view.jsx +++ b/src/views/contact-management-view.jsx @@ -148,7 +148,11 @@ function AddressBook({myUserId, onTopicSelected}) { {loading ?

加载中…

: sortedKeys.length === 0 ?

暂无好友

: null} {sortedKeys.map(initial =>

{initial}

{groups[initial].map(person => )}
)} @@ -377,7 +381,7 @@ function Profile({uid, friend, onTopicSelected, onToast}) { function Toggle({checked, onChange}) { return ; } -function Settings({uid, initialBlocked, onToast}) { +function Settings({uid, initialBlocked, pinBlocked, onToast}) { // Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a // real, current friend (see seedProfile's own comment), which used to leave this page's header // blank instead of just stuck like Profile, but no less wrong. @@ -390,7 +394,11 @@ function Settings({uid, initialBlocked, onToast}) { const [remarkDraft, setRemarkDraft] = React.useState(''); React.useEffect(() => { if (!uid) return; - getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {}); + // pinBlocked:确认弹窗把这个页面当背景渲染,并用 initialBlocked 摆出"操作之后"的样子。 + // 这时不能让这次拉取回来的真实关系(还是 friend)把它改回去——原来会导致弹窗一弹出来, + // 背后的拉黑开关自己跳回"未拉黑",同时"删除好友"红条又冒出来,跟参考图不符也跟弹窗 + // 正在确认的这件事自相矛盾。 + getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {}); // GET /users/{id}/profile never exposes the real remark (backend's userProfilePayload always // calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 -- // folding an empty remark into display_name instead of returning a separate field), only the @@ -480,7 +488,7 @@ function ConfirmSheet({type, uid, tinode, onToast}) { call.then(() => { onToast(config.toast); navigate(config.route, config.keepUid ? uid : undefined); }) .catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false)); }; - return
+ return

{config.title}

{config.text}

diff --git a/src/views/group-management-view.jsx b/src/views/group-management-view.jsx index 7353fed3..50e78e87 100644 --- a/src/views/group-management-view.jsx +++ b/src/views/group-management-view.jsx @@ -24,8 +24,24 @@ const navigate = (view, topic) => { // 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for // viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it // can't go through this file's own `navigate` helper above without clobbering the wrong hash param. -const navigateToProfile = userId => HashNavigation.navigateTo( - HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId)); +// 除了跳转,还必须先把已知资料写进 'profile-seed:' 缓存 —— 目标页(第5章的 Profile)会先读这个种子, +// 因为 GET /users/{id}/profile 对非好友按隐私默认直接 403(imPrivacyAllowsProfile)。群成员里 +// 大部分不是好友,不 seed 的话点进去就是"资料加载失败,请重试"。第5章自己的入口 +// (contact-management-view.jsx 的 seedProfile)一直是这么做的,这里漏了。 +const navigateToProfile = (userId, member) => { + if (member && userId) { + writeCachedData('profile-seed:' + userId, { + user_id: userId, + display_name: member.group_nickname || member.display_name || userId, + avatar_file_id: member.avatar_file_id, + ...(member.signature ? {signature: member.signature} : {}), + ...(member.region ? {region: member.region} : {}), + ...(member.gender ? {gender: member.gender} : {}) + }); + } + HashNavigation.navigateTo( + HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId)); +}; export const GROUP_MANAGEMENT_VIEWS = [ 'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', @@ -60,6 +76,27 @@ function groupBy(list, keyFn) { return Object.keys(groups).sort().map(key => [key, groups[key]]); } +// GET /v1/im/groups 的 name/member_count 目前不可信:handler 用的是 store.Users.GetSubs,而 store 自己的 +// 注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),可 groupListItemPayload +// 偏偏去读 sub.GetPublic() 取名字、sub.GetSubCnt() 取人数——于是每个群都返回自己的 topic id 当名字、 +// 0 当人数(应该用 GetTopics,它会把 Public 拷到 subscription 上)。已记入后端问题清单。 +// 在后端修好之前,用本地 Tinode 会话缓存兜底——消息首页的群名一直是从这里取的,是准的。 +function groupDisplayName(tinode, group) { + if (group.name && group.name !== group.topic) return group.name; + const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null; + const pub = t && t.public; + // 群的 public 用的是 `name`(实测本地缓存就是 {name:"扫码测试群", version:1}),后端自己的 + // groupName() 也是按 name/display_name/fn 这个顺序找的,保持一致。 + const local = pub && (pub.name || pub.display_name || pub.fn); + return local || group.name || group.topic; +} + +function groupMemberCount(tinode, group) { + if (group.member_count) return group.member_count; + const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null; + return (t && t.subcnt) || 0; +} + function shortDate(iso) { if (!iso) return ''; const d = new Date(iso); @@ -82,9 +119,11 @@ const GROUPS_DEMO = { }; function Icon({children}) { return ; } -function Header({title, back = 'group-list', backTopic, right, onRight}) { +// onBack:少数页面的返回目标不是"另一个侧栏视图",而是要关掉侧栏回到某个会话(需要写 path[1], +// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。 +function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) { return
- +

{title}

{right ? : }
; @@ -118,7 +157,7 @@ function Toggle({checked, onChange, disabled}) { className={'group-switch' + (checked ? ' checked' : '')} onClick={() => !disabled && onChange(!checked)}>; } -function GroupList({onToast}) { +function GroupList({tinode, onToast}) { // Only the unfiltered (query === '') result is worth caching -- caching a search result under the // same key would show stale search results instead of the real list on the next plain visit. const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null}); @@ -163,7 +202,7 @@ function GroupList({onToast}) { current.length ? current.map(group => ) :
groups这里还没有群聊 @@ -268,7 +307,13 @@ function Announcement({topic, myRole, fromChat, onToast}) { // handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical // path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so // "back" can return to the chat (close the sidepanel) instead of always forcing group-settings. - const backView = fromChat ? '' : 'group-settings'; + // 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该 + // 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数, + // path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。 + // 这里显式把 topic 写回 path[1]。 + const backToChat = React.useCallback(() => { + HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic)); + }, [topic]); React.useEffect(() => { if (!topic) return; const cached = readCachedData('group-announcement:' + topic); @@ -286,9 +331,9 @@ function Announcement({topic, myRole, fromChat, onToast}) { setData(d); setEditing(false); onToast('群公告已发布'); }).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false)); }; - if (!data) return

{loadError ? '群公告加载失败' : '加载中…'}

; + if (!data) return

{loadError ? '群公告加载失败' : '加载中…'}

; return
-
{canEdit ?