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
-