From 5a07bb4e3b6877c9b8e84c1cf338face45a69de1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 3 Sep 2026 18:31:26 +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=20P0=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=97=AE=E9=A2=98=EF=BC=88=E7=AC=AC=E4=B8=80=E6=89=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 八个 agent 对 11 章逐页对照设计稿+后端源码后,先修其中"功能不可用/崩溃/谎报成功"级别的: 崩溃与数据错误 - content-library-view: Highlight 按 [start,len] 数组解构后端返回的 {start,end} 对象, 对象不可迭代会直接抛 TypeError,任何文本搜索命中都让整个结果区渲染崩掉。服务端偏移还叠加 两个问题(Go 字节下标 vs JS UTF-16;偏移基准是全文但 snippet 会被开窗截断),改为在展示 文本上本地匹配关键词。 - content-library-view: firstAttachment 用 Drafty.attachments,SDK 里硬过滤 tp=='EX' 只认文件附件,图片(IM)/视频(VD)/语音(AU) 一个都取不到——收藏缩略图、语音播放源、聊天记录 图片视频宫格全部拿不到资源。改用 Drafty.entities 按类型分派(chat-message.jsx 已有先例)。 - message-center-view: 系统通知的机器码兜底会把真实数据吞掉(日报提醒的 summary 就是日期 字符串 "2026-08-20",会议通知的 summary 是标题),改为查表命中才替换。 - message-center-view: 会话列表漏了 avatar 字段,每行都只能落到兜底图标;同文件 frequentContacts() 本来就是对的,照它补上。 功能不可用 - auth-onboarding-view: 首次开启青少年模式是死路——设完密码丢弃了服务端返回的 enable_action_token,从不调 enableYouthMode,用户走完短信验证+设密码后模式仍是关的。 现在拿着这个 token 把最后一步做完。 - group-management-view: "投诉/举报"是个 onToast('暂未开放') 死桩,而 target_type='group' 这条链路早就通了。接上真实举报页。 - tinode-web: 刷新/深链会被弹回首页。两个原因叠加:初始化白名单还是上游 Tinode 的 ['cred','reset','register'];handleLoginSuccessful 无条件把 hash 改写成 workbench, 刷新触发的自动登录也走这条。抽出 SIDE_PANEL_VIEWS 常量复用,并区分登录流程与自动登录。 - widgets/meta-message: deleted 分支从未实现,撤回一条消息后时间线上什么都不剩。 谎报成功 / 判定错误 - tinode-web + personal-settings-view: 改密码不返回 Promise,服务端拒绝时照样弹"密码已更新"。 - group-management-view: 禁言批量读 result.succeeded,但该接口返回的是 {topic, items}, succeeded 恒为 undefined,不管成没成都弹"设置成功"。两个接口结构不同,分开判定。 - workplace-collaboration-view: 会议取消状态写成 'cancelled',后端是 'canceled' 单 l, 会议取消后"取消会议"按钮还挂着。 样式与显示 - base.css: 删掉旧版 .organization-department 的 padding-left !important,它压过按层级算出的 内联缩进,导致部门行全平、只有人员行缩进。 - base.css: 补 .youth-password-gate(原来 0 条规则),PIN 框缺 position:relative 和居中。 - security-support-view: shield_lock 不在经典 Material Icons 里(只有 Symbols),会显示成 字面文本;.risk-info-page 也是 0 条规则,整页渲染成裸项目符号列表。改用 verified_user 并 补上 .risk-dialog。 - contact-management-view: 好友资料页性别直接输出后端枚举,显示成 "male"。 - workplace-collaboration-view: 层级标注两条路径两种格式(树形深三层变"3级"、搜索硬传"三"), 统一成一个 helper。 已记录不在前端修的后端缺口:recallMessage 的 version 只能传 1,因为所有消息读取接口 (messagePayload/messageSearchPayload/mediaMessagePayload/favoriteSnapshot)都不下发 version, 冲突错误里也不回传真实值,被编辑过的消息撤回必然失败。 Co-Authored-By: Claude Opus 5 --- css/base.css | 40 +++--- src/lib/im-api.js | 5 + src/views/auth-onboarding-view.jsx | 28 ++++- src/views/contact-management-view.jsx | 7 +- src/views/content-library-view.jsx | 47 ++++++-- src/views/group-management-view.jsx | 33 ++++- src/views/message-center-view.jsx | 17 +-- src/views/messages-view.jsx | 7 ++ src/views/personal-settings-view.jsx | 15 ++- src/views/security-support-view.jsx | 9 +- src/views/tinode-web.jsx | 122 +++++++++++-------- src/views/workplace-collaboration-view.jsx | 15 ++- src/widgets/meta-message.jsx | 6 + umd/index.dev.js | 134 +++++++++++++++------ umd/index.dev.js.map | 2 +- umd/src_views_messages-view_jsx.dev.js | 14 ++- umd/src_views_messages-view_jsx.dev.js.map | 2 +- 17 files changed, 365 insertions(+), 138 deletions(-) diff --git a/css/base.css b/css/base.css index d80b9d01..382d7707 100644 --- a/css/base.css +++ b/css/base.css @@ -653,21 +653,11 @@ color: #8b929b; } -.organization-root > .material-icons:first-child { - color: #2f73d2; -} - -.organization-department { - padding-left: 1.75rem !important; -} - -.organization-department > .material-icons:first-child { - color: #e79a32; -} - -.organization-members button { - padding-left: 2.5rem; -} +/* 旧版组织架构的 .organization-root / .organization-department / .organization-members 三条规则 + 已删除:前两个类名 JSX 里早就没人用了,而 .organization-department 那条 + `padding-left: 1.75rem !important` 会压过新版按层级算出来的内联 paddingLeft, + 导致一级/二级/三级部门行左边距完全一样、树形层级看不出来(人员行没有 !important,缩进正常, + 结果是"部门全平、人员缩进",比不缩进更乱)。新版完整规则在下面 .organization-department 那一块。 */ .organization-members img { width: 2.5rem; @@ -6440,6 +6430,26 @@ div.toast.show { font-size: 1.8rem; } +/* 青少年模式的 PIN 页复用了下面的 .auth-pin-boxes / .auth-pin-input / .auth-primary, + 但它自己的容器类 .youth-password-gate 原来在整个 base.css 里一条规则都没有。缺的正是 + .auth-pin-content 提供的两件事:position:relative(.auth-pin-input 是 position:absolute + + top:11rem,没有定位祖先就会跑到更外层去,点 PIN 框重新聚焦会点空)和 align-items:center + (.auth-pin-boxes 是 grid,父级的 text-align:center 对它无效,不会居中)。 */ +.youth-password-gate { + display: flex; + position: relative; + flex-direction: column; + align-items: center; + width: min(100%, 32rem); + margin: 0 auto; + padding: 3rem 2rem 2rem; +} + +.youth-password-gate .auth-primary { + width: min(100%, 24rem); + margin-top: 3rem; +} + .auth-pin-boxes { display: grid; grid-template-columns: repeat(4, 4rem); diff --git a/src/lib/im-api.js b/src/lib/im-api.js index 7d865b9f..8a69c3d5 100644 --- a/src/lib/im-api.js +++ b/src/lib/im-api.js @@ -449,6 +449,11 @@ export const getConversationSettings = topic => imGet('/conversations/' + encode export const updateConversationSettings = (topic, body) => imPatch('/conversations/' + encodeURIComponent(topic) + '/settings', body); // -- Messages (Ch.4/7). -- +// version 只能默认传 1:服务端做的是乐观锁校验(tenant_adapter.go:1963 要求 msg.Version 与传入值 +// 完全相等,否则 ErrDuplicate),但**没有任何消息读取接口下发过 version 字段**——messagePayload / +// messageSearchPayload / mediaMessagePayload / favoriteSnapshot 全都没有,冲突时的错误里也不回传 +// 真实值。所以前端拿不到正确的版本号,被编辑过(version>1)的消息撤回必然失败。这是后端缺口, +// 已记入《IM-后端问题清单.md》,不在前端绕。 export const recallMessage = (topic, seq, version = 1, reason = 'mistake') => imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/recall', {version, reason}); export const reviseMessage = (topic, seq, content, version = 1) => diff --git a/src/views/auth-onboarding-view.jsx b/src/views/auth-onboarding-view.jsx index 530ec140..be57e2ff 100644 --- a/src/views/auth-onboarding-view.jsx +++ b/src/views/auth-onboarding-view.jsx @@ -4,7 +4,8 @@ import HashNavigation from '../lib/navigation.js'; import { resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile, requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset, - createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword + createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword, + getYouthMode, enableYouthMode } from '../lib/im-api.js'; const USERNAME_RE = /^[a-z0-9]{1,10}$/; @@ -674,9 +675,30 @@ function IndependentPassword({authenticated}) { if (!actionToken) { setError('身份验证已过期,请返回重新验证'); return; } setBusy(true); try { - await setIndependentPassword(pin, 'youth_mode', actionToken); + const result = await setIndependentPassword(pin, 'youth_mode', actionToken); sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE); - setError(''); setToast('独立密码设置成功'); setPin(''); + setPin(''); + // 设密码只是"首次开启青少年模式"这条路的中间一步,不是终点。服务端在这一步就把下一步 + // 要用的凭证一起发回来了(enable_action_token + next_action:"enable_youth_mode", + // 见 server/im_http.go:6178-6189)。原来这里把它整个丢掉、只弹一句"设置成功"就停在 + // PIN 页——用户走完短信验证+设密码,青少年模式其实还是关的,得自己摸回设置页再输一遍。 + // 现在拿着这个 token 把最后一步做完。 + const enableToken = result && result.enable_action_token; + if (enableToken) { + try { + const current = await getYouthMode(); + await enableYouthMode(current.policy_version, enableToken); + setError(''); setToast('青少年模式已开启'); + window.setTimeout(() => navigate('youth-mode-on'), 600); + return; + } catch (enableErr) { + // 密码确实设成功了,只是自动开启这一步没成,如实说明并把人送回青少年模式页手动开 + setError('独立密码已设置,但自动开启未成功:' + (enableErr.message || '请返回重试')); + window.setTimeout(() => navigate('youth-mode-off'), 1200); + return; + } + } + setError(''); setToast('独立密码设置成功'); } catch (err) { setError(err.message || '设置失败,请返回重新验证身份'); } finally { diff --git a/src/views/contact-management-view.jsx b/src/views/contact-management-view.jsx index b0edb0b1..e9528b5e 100644 --- a/src/views/contact-management-view.jsx +++ b/src/views/contact-management-view.jsx @@ -191,6 +191,9 @@ function FriendRequests({onToast}) { ; } +// 与 personal-settings-view.jsx 的 GENDER_OPTIONS 一一对应,两处写的是同一套后端枚举 +const GENDER_LABELS = {male: '男', female: '女', other: '其他'}; + // `GET /users/{id}/profile` (getUserProfile) 403s for anyone who isn't already a friend, by design // (server's default `profile_visibility` is "friends"-only, server/im_http.go's imPrivacyAllowsProfile). // But the two places that hand you a *stranger* to look at -- an account search hit and a scanned @@ -357,7 +360,9 @@ function Profile({uid, friend, onTopicSelected, onToast}) {

{profile.display_name}

{profile.public_account ?

用户名:{profile.public_account}

: null} {profile.region ?

地区:{profile.region}

: null}
qr_code
-
{profile.gender ?
性别
{profile.gender}
: null} + {/* 后端存的是 male/female/other 枚举(personal-settings-view.jsx:203 的 GENDER_OPTIONS 写进去的), + 原来直接原样输出,资料页上显示的是英文 "male"。 */} +
{profile.gender ?
性别
{GENDER_LABELS[profile.gender] || profile.gender}
: null}
个性签名
{profile.signature || '此人很懒什么都没留下~'}
{isFriend ?
diff --git a/src/views/content-library-view.jsx b/src/views/content-library-view.jsx index 83b5a594..38c41ca9 100644 --- a/src/views/content-library-view.jsx +++ b/src/views/content-library-view.jsx @@ -62,11 +62,23 @@ function Search({value, onChange, placeholder = '搜索'}) { return
; } -function Highlight({text, ranges}) { - if (!ranges || !ranges.length) return text; - const parts = []; let last = 0; - ranges.forEach(([start, len], i) => { - parts.push(text.slice(last, start), {text.slice(start, start + len)}); - last = start + len; - }); - parts.push(text.slice(last)); +// 不用服务端返回的 matches 偏移,改为在展示文本上本地匹配关键词。服务端那份偏移有三层问题, +// 全都对不上前端要高亮的这段文字: +// 1. 结构是 [{start,end}] 对象数组(im_http.go:9151),按 [start,len] 数组解构会直接抛 +// TypeError(对象不可迭代),整个搜索结果渲染崩掉; +// 2. start/end 是 Go 的字节下标(strings.Index + len(query)),中文一字三字节,跟 JS 的 +// UTF-16 下标永远错位; +// 3. 偏移基准是完整原文,但 snippet 在超过 120 字时会被开窗截断(im_http.go:9128-9139), +// 两者基准不一致——而且那段开窗自己也混用了字节下标和 rune 切片。 +// 本地匹配没有这些歧义:所见即所高亮。 +function Highlight({text, query}) { + const needle = (query || '').trim(); + if (!needle || !text) return text; + const parts = []; + const haystack = text.toLowerCase(); + const lowered = needle.toLowerCase(); + let last = 0; + for (let at = haystack.indexOf(lowered); at !== -1; at = haystack.indexOf(lowered, at + lowered.length)) { + if (at > last) parts.push(text.slice(last, at)); + parts.push({text.slice(at, at + needle.length)}); + last = at + needle.length; + } + if (!parts.length) return text; + if (last < text.length) parts.push(text.slice(last)); return parts; } @@ -494,7 +521,7 @@ function History({view, hashParams, myUserId, onToast}) { view === 'history-search' ?
{list.map(item => )}
: diff --git a/src/views/group-management-view.jsx b/src/views/group-management-view.jsx index 4936cf6d..7353fed3 100644 --- a/src/views/group-management-view.jsx +++ b/src/views/group-management-view.jsx @@ -396,7 +396,17 @@ function GroupSettings({topic, myUserId, tinode, onToast}) { const t = tinode ? tinode.getTopic(topic) : null; (t ? t.delMessagesAll(false) : Promise.resolve()).then(() => onToast('聊天记录已清除')).catch(err => onToast(err.message || '操作失败')); }}>清空聊天记录chevron_right - + {/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用), + 这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样, + 举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数 + 只会写 topic,所以直接拼 hash。 */} + @@ -659,8 +669,19 @@ function MemberPicker({type, topic, onToast}) { setBusy(true); const call = mute ? muteGroupMembers(topic, selected.map(user_id => ({user_id}))) : addGroupAdmins(topic, selected); call.then(result => { - const failed = result && result.succeeded ? result.succeeded.filter(s => !s.ok) : []; - setToast(failed.length ? `成功设置 ${selected.length - failed.length} 人,${failed.length} 人设置失败` : '设置成功'); + // 两个批量接口的响应结构不一样,不能用同一套判定: + // - 添加管理员 POST .../admins/batch 返回 succeeded[],每项带 ok/reason(im_http.go:5377-5392) + // - 添加禁言 POST .../mutes 返回 {topic, items},只有成功的才进 items, + // 非法 uid 会被静默 continue(im_http.go:5486-5488),根本没有 succeeded 字段 + // 原来两条路都读 succeeded,禁言那条恒为 undefined → failed 恒为空 → 不管实际成没成都弹"设置成功"。 + let failedCount; + if (mute) { + const done = (result && Array.isArray(result.items)) ? result.items.length : selected.length; + failedCount = Math.max(selected.length - done, 0); + } else { + failedCount = (result && Array.isArray(result.succeeded)) ? result.succeeded.filter(s => !s.ok).length : 0; + } + setToast(failedCount ? `成功设置 ${selected.length - failedCount} 人,${failedCount} 人设置失败` : '设置成功'); window.setTimeout(() => navigate(back, topic), 900); }).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false)); }; @@ -867,8 +888,10 @@ function GroupDescriptionEditor({topic, onToast}) { return
-