import React from 'react'; import { Tinode } from 'tinode-sdk'; import Navigation from '../lib/navigation.js'; import { BASE_APP_NAME } from '../config.js'; import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js'; import { makeImageUrl } from '../lib/blob-helpers.js'; import { useCachedData } from '../lib/use-cached-data.js'; import { topicDisplayName } from '../lib/utils.js'; // Must go through setUrlSidePanel (not a bare navigateTo(view), which replaces the whole hash) so // switching panels from "messages-home" doesn't silently drop path[1] -- the currently open // conversation topic, which coexists with this panel in the split view once a chat is selected. // `topic` 可选:系统消息里的群事件通知需要带着群 topic 跳进对应群聊设置页,写法与 // group-management-view.jsx 的同名辅助函数保持一致(不带 topic 时显式清掉,避免把上一个页面 // 残留的 topic 参数带进不相关的页面)。 const navigate = (view, topic) => { let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view); hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic'); Navigation.navigateTo(hash); }; // BottomNavigation's 消息<->工作台 switch is the app's root-level tab bar, not a navigation to a // "different page" in the back-stack sense -- same bug/fix as content-library-view.jsx's // navigateReplace (see its comment): navigate() here pushed a history entry on every tab click, so // bouncing between 消息 and 工作台 a few times (a completely normal usage pattern for the app's only // two bottom tabs) made the back button just cycle through past tab switches instead of leaving the // app, exactly like a standard bottom-tab-bar app's back button should not have to unwind every past // tab switch to back out. const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view)); // Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same // direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by // a user screenshot showing every contact opening the identical "接语彤" script). Real "frequently // contacted" data doesn't exist as its own bootstrap field, so this derives it from the same // `chatList` the conversation list below already uses: P2P topics only (a "people you talk to" rail // isn't the place for groups), most-recently-touched first. function frequentContacts(chatList) { if (!chatList || chatList.length === 0) return []; return chatList .filter(item => item.topic && Tinode.isP2PTopicName(item.topic)) .slice() .sort((a, b) => new Date(b.touched || 0).getTime() - new Date(a.touched || 0).getTime()) .slice(0, 8) .map(item => ({ id: item.topic, name: (item.public && item.public.fn) || '未命名', avatar: makeImageUrl(item.public && item.public.photo), online: !!item.online })); } // Matches docs/images/消息页面—长按弹出.png exactly (9 items, in this order -- no "编辑" despite // bootstrap.message_policy.supported_actions listing it; the mock predates that field and the // prototype just doesn't surface an edit action here). 'recall' is gated against supported_actions // (server/im_http.go serveIMBootstrap); favorite/forward/translate/select are pure client affordances. const MESSAGE_ACTIONS = [ {id: 'quote', icon: 'format_quote', label: '引用'}, {id: 'copy', icon: 'content_copy', label: '复制'}, {id: 'favorite', icon: 'inventory_2', label: '收藏'}, {id: 'translate', icon: 'translate', label: '翻译'}, {id: 'forward', icon: 'reply', label: '转发'}, {id: 'recall', icon: 'undo', label: '撤回', gated: true}, {id: 'delete', icon: 'delete_outline', label: '删除', gated: true}, {id: 'select', icon: 'select_all', label: '多选'}, // Not in bootstrap.message_policy.supported_actions at all (that list only covers 'recall' for the // sender's own message) -- left ungated like the other pure client affordances above. {id: 'group_recall', icon: 'restart_alt', label: '群撤回'} ]; function Icon({children}) { return ; } function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2200); return () => window.clearTimeout(timer); }, [message, onDone]); return message ?
{message}
: null; } function Avatar({item, small = false}) { // The tone modifier is namespaced ('tone-orange', not 'orange') because base.css's original // Tinode styles already define bare `.orange`/`.green`/`.blue` as generic `color: ... !important` // text-color utility classes -- an unprefixed tone class collides with those and, since // `!important` beats any selector this component could use to fight back, silently overrides the // intended white icon-on-tint background to whatever that generic utility's text color is (this is // exactly why the default/no-tone case rendered an invisible blue-on-blue icon: 'blue' is one of // the collision names). const className = 'messages-avatar ' + (small ? 'small ' : '') + 'tone-' + (item.tone || 'blue'); return
{item.avatar ? : {item.icon || 'person'}} {item.online !== undefined ? : null}
; } // A chat list row needs a one-line summary even when the latest message isn't plain text. This is a // plain-Chinese equivalent of lib/formatters.js's previewFormatter (which needs react-intl wired in, // inconsistent with this file's all-hardcoded-Chinese mock strings) -- just enough to label the // message by Drafty entity type for a conversation list preview. function draftyPreview(content) { if (typeof content === 'string' || content == null) return content || ''; if (Array.isArray(content.ent) && content.ent.length) { const label = {IM: '[图片]', VD: '[视频]', AU: '[语音]', EX: '[文件]', TC: '[名片]', VC: '[通话]'}[content.ent[0].tp]; if (label) return label; } // 服务端的 system_notice/system_action(好友通过、群成员加入、群邀请卡片等,见 chat-message.jsx // 对 x-im-type 的处理)以及合并转发消息都不是 Drafty,也没有 `txt` 字段,真正的可读文案在 // `text`(system_*)或 `comment`/`title`(forward_bundle)——原来这里只认 `txt`,这三类消息在 // 会话列表的预览行一律回退成没有信息量的"[消息]",即使内容本身早就是一句完整人话 // (比如"邀请你加入群聊。"),聊天页里点开看到的和列表预览完全对不上。 if (typeof content.text === 'string' && content.text) return content.text; if (typeof content.comment === 'string' && content.comment) return content.comment; if (typeof content.title === 'string' && content.title) return '[聊天记录] ' + content.title; return content.txt || '[消息]'; } function formatConversationTime(ts) { if (!ts) return ''; const date = new Date(ts); const now = new Date(); const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000); const hhmm = () => date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}); if (days === 0) return hhmm(); // 参考图 docs/images/消息.png 里昨天的会话显示的是"昨天21:03",带具体时间,不是只有"昨天" if (days === 1) return '昨天' + hhmm(); if (days < 7) return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()]; return date.toLocaleDateString('zh-CN', {month: 'numeric', day: 'numeric'}); } // 参考图 系统消息.png 的时间格式是"6月28日 10:04"——不带年份、中文月日、不带秒。原来直接用 // toLocaleString('zh-CN') 默认格式,会输出"2026/8/20 18:04:00"(带年份、斜杠、秒),跟设计不符。 function formatNotificationTime(ts) { const d = new Date(ts); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); return `${d.getMonth() + 1}月${d.getDate()}日 ${hh}:${mm}`; } // Entries (system messages / file assistant) come from bootstrap.entries and are always shown -- // unlike the old mock, they don't disappear once the user has real conversations (that was a bug). function entryConversations(bootstrap) { if (!bootstrap || !bootstrap.entries) return []; const list = []; const sys = bootstrap.entries.system_messages; if (sys && sys.enabled) { // Matches docs/images/消息.png ("最新公告"). bootstrap doesn't carry a preview of the latest // notification, so this is a static label like the rest of that screenshot's demo content. list.push({id: 'system', title: '系统消息', summary: '最新公告', icon: 'notifications', tone: 'orange', system: true, unread: sys.unread_count || 0}); } const files = bootstrap.entries.file_assistant; if (files && files.enabled) { list.push({id: 'files', title: '文件助手', summary: '你上传1份文件', icon: 'folder', tone: 'green', fileAssistant: true, topic: files.topic, unread: files.unread_count || 0}); } return list; } function normalizeConversations(chatList, myUserId) { if (!chatList || chatList.length === 0) return []; return chatList.map((item, index) => { const latest = typeof item.latestMessage === 'function' ? item.latestMessage() : null; let summary = item.private && item.private.comment ? item.private.comment : '暂无消息'; if (latest) summary = draftyPreview(latest.content); if (latest && latest.from === myUserId) summary = '我:' + summary; const isGroup = typeof item.topic === 'string' && item.topic.startsWith('grp'); return { id: item.topic || item.user || String(index), title: topicDisplayName(item.topic, item.public, '未命名会话'), summary, time: formatConversationTime(item.touched), unread: Math.max(Number(item.unread) || 0, 0), // 真实头像:跟同文件 frequentContacts() 一样从 public.photo 取,原来漏了这一项, // 导致会话列表每一行都只能落到下面的兜底图标,永远显示不出头像。 avatar: makeImageUrl(item.public && item.public.photo), // 没有真实头像时的兜底图标要区分群聊和单聊,否则群聊也画成单人像,列表里分不出哪条是群 icon: isGroup ? 'group' : 'person', // 在线状态只对单聊有意义:群不存在"在线",传 undefined 让 Avatar 不渲染那个状态点 online: isGroup ? undefined : !!item.online, muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()), // 原来读的是 item.pinned——这个键在原生订阅对象上根本不存在,永远 undefined,置顶开关点了 // 会话列表从来没有过任何视觉变化。真实存放位置是 subscription 原生 private.pinned 字段, // 但只对**群聊**成立:group-management-view.jsx 的置顶走 updateGroupMemberSettings -> // serveIMGroupMemberSettings,服务端确实写的是 Subs.Private,随原生 {meta} 同步下来(真实 // 验证:开关点亮后要等下一次重连/刷新才会在这里体现——同一会话内没有实时 {pres} 通知去让 // 已连接的这份 subscription 缓存失效,这是已知的、这次没有顺手解决的次要延迟,不是没生效)。 // contact-management-view.jsx 的单聊置顶走的是完全不同的 updateConversationSettings -> // serveIMConversationSettings,服务端存进独立的 im_conversation_settings 表,从不写入原生 // Subs.Private——单聊置顶因此不管等多久重连都不会在这里出现,这是真实的后端架构缺口(没有 // 批量查询会话设置的接口,前端也不可能为渲染一屏会话列表挨个按 topic 发请求),已记录到 // 后端问题清单,不是这一行能修的。 pinned: !!(item.private && item.private.pinned) }; }); } function BottomNavigation() { return ; } function QuickMenu({onClose, onAction, permissions}) { const actions = [ {id: 'friends', icon: 'group', label: '朋友'}, {id: 'profile', icon: 'person_outline', label: '我的'}, {id: 'create-group', icon: 'add_comment', label: '创建群组', permission: 'create_group'}, {id: 'add-friend', icon: 'person_add_alt', label: '添加好友', permission: 'user_search'}, {id: 'scan', icon: 'qr_code_scanner', label: '扫一扫', permission: 'scan_qr'} ].filter(item => !item.permission || !permissions || permissions[item.permission] !== false); return
e.stopPropagation()} role="menu"> {actions.map(item => )}
; } // 1.10 实名认证提醒:docs/images/实名认证(安卓).png 显示的是不可跳过的强制态(单按钮"去实名 // 认证",没有关闭/稍后处理)——但那张图对应的是 bootstrap.security.realname.required===true 这个 // 分支,这个环境(以及目前后端唯一会返回的默认配置)实际是 required:false / allow_skip:true。 // 照抄参考图做成完全关不掉的弹层,等于把"实名认证目前是可选的"这个真实配置,实现成挡住所有人的 // 强制墙——所以按这两个字段分叉:允许跳过时加一个参考图没画但后端明确允许的关闭按钮;且遵守 // remind_after,跳过后在这个时长内不重复弹(不是每次进消息首页都弹一次)。 function parseRemindAfter(text) { const m = /^(\d+)\s*([hdm])$/i.exec(String(text || '').trim()); if (!m) return 24 * 60 * 60 * 1000; const n = Number(m[1]); return m[2].toLowerCase() === 'd' ? n * 86400000 : m[2].toLowerCase() === 'h' ? n * 3600000 : n * 60000; } function RealnameReminderModal({allowSkip, onDismiss}) { return
e.stopPropagation()}> {allowSkip ? : null}

实名认证提醒

根据国家法律法规要求,您需先进行实名认证才能使用该项 APP

; } function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOpenScanner}) { const [query, setQuery] = React.useState(''); const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu'); const [toast, setToast] = React.useState(''); // Was `useState(null)` + its own effect -- refetched from scratch (blank entries/badges for a // beat) every time this component remounted, e.g. after a trip to another sidepanel and back. const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]); // sidepanel-view.jsx 会在 sidePanelSelected 已经是 'messages-home'(例如收藏夹里的旧链接、浏览器 // 从上次关闭前恢复的标签页)但 myUserId 还没就绪时,照样把这个视图渲染出来(它自己的 // `fullPage = !myUserId || view !== 'messages-home'` 分支就是为这种情况准备的)——如果这时候 // token 续登录失败或者还没跑完,原来这里只看 `!!myUserId && connected===false`,myUserId 本身是 // null 时这个条件恒为 false,于是不会进入"加载中"分支,会话列表和 bootstrap 又因为 // useCachedData 的 key 是 null 而永远不发请求,最终呈现的是一句容易误导人的"暂无会话/未找到 // 相关会话"——用户会以为账号是空的,而不是"还没登录成功"。myUserId 本身为空也按"未连接"处理, // 至少诚实地展示"加载中"而不是一个看起来正常但其实是半成品的空列表。 const offline = view === 'messages-offline' || !myUserId || connected === false; // "置顶" only ever changed a background tint (see the `pinned` comment inside // normalizeConversations) -- every other IM this product's users have used floats a pinned chat to // the top of the list and keeps it there, so a tint-only "pin" reads as broken even where the // underlying flag is genuinely correct (group chats). Array.prototype.sort is stable (guaranteed // since ES2019), so this only reorders pinned-vs-not and otherwise preserves chatList's own // ordering. Applied after normalizing, not to the concatenated list, so the two fixed system // entries (system_messages/file_assistant) -- which have no `pinned` concept at all -- stay // pinned to the very top the way they always have, ahead of even a pinned real conversation. const realConversations = normalizeConversations(chatList, myUserId) .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)); const conversations = entryConversations(bootstrap).concat(realConversations); const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase())); const frequent = frequentContacts(chatList); // bootstrap.security.realname carries required/allow_skip/remind_after, but -- confirmed live -- // its handler (serveIMBootstrap) builds that block from a 4-field literal that never includes // submission_id, unlike the dedicated GET /security/realname endpoint (realNamePayload). The // dismissKey fix below depends on submission_id to tell two separate rejections apart; sourcing // `status` from bootstrap alone made that fix a no-op in practice (it always fell back to `status`, // reproducing the exact bug it was meant to fix). Fetching this separately, the same // useCachedData-with-retry way bootstrap itself is fetched, gets the real submission_id. const realnameMeta = bootstrap && bootstrap.security && bootstrap.security.realname; const [realnameStatus] = useCachedData(myUserId ? 'realname-status:' + myUserId : null, getRealnameStatus, [myUserId]); const [reminderDismissed, setReminderDismissed] = React.useState(false); // Keyed by submission_id (a new one is minted per resubmission, server/im_http.go's // realNamePayload/serveIMRealNameSubmissions), not just status -- two separate rejections both // report status:"rejected", and without something that actually changes between them, dismissing // the first would keep suppressing the reminder for a brand new rejection submitted and rejected // again within the same snooze window. Falls back to status for the states that never had a // submission (e.g. never-submitted "unverified", where there's only ever one such state anyway). const dismissKey = (myUserId && realnameStatus) ? `realname-reminder-dismissed:${myUserId}:${realnameStatus.submission_id || realnameStatus.status}` : null; let lastDismissedAt = 0; try { lastDismissedAt = dismissKey ? Number(localStorage.getItem(dismissKey) || 0) : 0; } catch (_) { /* ignore */ } const withinSnooze = lastDismissedAt > 0 && (Date.now() - lastDismissedAt) < parseRemindAfter(realnameMeta && realnameMeta.remind_after); const showRealnameReminder = !!realnameStatus && !reminderDismissed && !withinSnooze && (realnameStatus.status === 'unverified' || realnameStatus.status === 'rejected'); const dismissRealnameReminder = () => { if (dismissKey) { try { localStorage.setItem(dismissKey, String(Date.now())); } catch (_) { /* ignore */ } } setReminderDismissed(true); }; const selectConversation = item => { if (item.system) { navigate('system-messages'); } else if (item.fileAssistant && item.topic && onTopicSelected) { onTopicSelected(item.topic); } else if (chatList && chatList.length && onTopicSelected) { onTopicSelected(item.id); } else { navigate('chat-standard'); } }; const runQuickAction = item => { setMenuOpen(false); if (item.id === 'friends') return navigate('address-book'); if (item.id === 'profile') return navigate('personal-center'); if (item.id === 'create-group') return navigate('group-list'); if (item.id === 'add-friend') return navigate('add-friend'); if (item.id === 'scan' && onOpenScanner) return onOpenScanner(); setToast(item.id === 'scan' ? '扫码功能暂不可用' : `${item.label}页面将在对应章节中提供`); }; return
{offline ?
加载中
:

消息

}
{frequent.length > 0 ?

常联系的人

{frequent.map(item => )}
: null}
{filtered.map(item => )} {/* "未找到相关会话"(搜索图标)只在用户真的输入过搜索词却匹配不到时才准确——`conversations` 本身为空(例如尚未完成登录/数据尚未拉取到、或租户把系统消息与文件助手都关闭且用户还没有 任何会话)跟"搜索没匹配到"是两种不同情况,原来两者共用同一句"未找到相关会话"+放大镜 图标,会把"这个账号目前就是没有任何会话"说成"你的搜索没找到东西",用户没搜索过却看到 搜索无结果的图标和文案。 */} {filtered.length === 0 ? (query.trim() ?
search_off

未找到相关会话

:
forum

暂无会话

) : null}
{menuOpen ? setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null} setToast('')} /> {showRealnameReminder ? : null}
; } function MessageActions() { const [visible, setVisible] = React.useState(true); const [toast, setToast] = React.useState(''); const [selected, setSelected] = React.useState(''); const [supportedActions, setSupportedActions] = React.useState(null); React.useEffect(() => { getBootstrap().then(data => setSupportedActions(data.message_policy && data.message_policy.supported_actions)).catch(() => {}); }, []); // This screen is a static illustration of the long-press menu (not attached to a real message), so // there's no real (topic, seq) to act on -- the real wiring belongs in messages-view.jsx (chapter 4) // where an actual message exists. Here we only let the server's declared capabilities decide which // gated actions (recall/edit/delete) are worth showing at all. const actions = MESSAGE_ACTIONS.filter(item => !item.gated || !supportedActions || supportedActions.includes(item.id)); const runAction = item => { setSelected(item.id); if (item.id === 'copy' && navigator.clipboard) { navigator.clipboard.writeText('明天上午十点开项目会,请准时参加。').catch(() => {}); } setToast(`${item.label}操作已选择`); setVisible(false); }; return

项目协作群

今天 10:24

{selected ?

已选择:{MESSAGE_ACTIONS.find(item => item.id === selected).label}

: null}
{visible ?
setVisible(false)}>
e.stopPropagation()} role="menu"> {actions.map(item => )}
: null} setToast('')} />
; } // 系统通知的 summary 在多处后端代码里被直接填成机器码而不是人话:群事件统一写 // summary=event.EventType(server/im_group_events.go:42-44,所有群事件共用 title="群聊动态"), // 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700, // 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。 const NOTIFICATION_TEXT = { 'group.created': '你创建了一个新群聊', 'group.join_request.created': '有人申请加入你管理的群聊', // 实测 asn494180 账号的真实系统消息列表里出现过原样展示的 "group.announcement.updated"—— // db/mysql/tenant_groups.go 里 insertGroupEventTx 一共写了 9 种 group.* 事件码 // (announcement.updated/created/join_request.created/member.joined/member.left/ // member.updated/mute.updated/owner.transferred/role.updated/settings.updated),这张表原来 // 漏收了公告更新这一种,是 9 种里唯一没有查表命中、直接把机器码露给用户看的。 'group.announcement.updated': '群公告有更新', 'group.member.joined': '有成员加入了群聊', 'group.member.left': '有成员退出了群聊', 'group.member.updated': '群成员资料有更新', 'group.mute.updated': '群禁言设置有变更', 'group.owner.transferred': '群主已转让', 'group.role.updated': '群成员角色有变更', 'group.settings.updated': '群设置有更新', // 上面那 9 种都是 insertGroupEventTx 的静态字符串调用,唯独审批入群申请这一条不是: // TenantGroupJoinRequestResolve(tenant_groups.go:642)拼的是 // "group.join_request."+status,status 是 "accepted" 或 "rejected"(server/store/types/groups.go // 的 GroupJoinRequestStatusAccepted/Rejected),实际落进 summary 的是 // "group.join_request.accepted"/"group.join_request.rejected" 这两个完整机器码,跟本已收录的 // "group.join_request.created"(申请提交时那条)是三个不同的 key。当初核对 9 种静态事件码时 // 这条动态拼接的漏了,管理员在群管理页批准/拒绝入群申请后,申请人这边系统消息列表同样会原样 // 展示机器码——跟 group.announcement.updated 是完全同一类问题,只是触发路径不同(未在真实 // 环境里等到一条这样的通知,是读 im_be 源码逐个比对 insertGroupEventTx 调用点发现的)。 'group.join_request.accepted': '你的入群申请已通过', 'group.join_request.rejected': '你的入群申请被拒绝', accepted: '对方已通过你的好友申请', rejected: '对方拒绝了你的好友申请', // 新样本:security.risk.continue/cancel 事件的 summary 直接是 decision 字面量 // "continue"/"cancel"(风险确认审计记录,见 im-api.js 的 confirmRiskChallenge),同样是机器码—— // 两个 decision 枚举值只有一个"continue"曾被收录,"cancel"(第11章发消息前风险提示点"取消发送" // 产生)此前会直接把英文单词"cancel"展示在系统消息列表里。 continue: '你确认继续了一次安全风险提示', cancel: '你取消了一次风险操作确认' }; function systemNotificationText(item) { const summary = (item.summary || '').trim(); // 只在查表命中时才替换。后端的 summary 有些确实是机器码(好友申请通过时是 "accepted"), // 但也有些就是真实数据——日报提醒的 summary 是纯日期字符串 // (db/mysql/tenant_adapter.go:4917 传 date.Format("2006-01-02")),会议通知的 summary 是会议标题。 // 一旦按"看起来像机器码就吞掉"来判断,这些真实内容会被换成一句无意义的兜底文案。 const mapped = NOTIFICATION_TEXT[summary] || NOTIFICATION_TEXT[item.event_type]; if (mapped) return mapped; return summary || '您有一条新的系统通知。'; } function SystemMessages({authenticated}) { const [items, setItems] = React.useState([]); const [cursor, setCursor] = React.useState(''); const [loadingMore, setLoadingMore] = React.useState(false); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(''); const [toast, setToast] = React.useState(''); React.useEffect(() => { getSystemNotifications().then(data => { setItems(data.items || []); setCursor(data.next_cursor || ''); }) .catch(err => setError(err.message || '加载失败')) .finally(() => setLoading(false)); }, []); // GET /system-notifications does return next_cursor whenever there's more (real accounts with // 50 unread saw it truncate silently at 20, with no way to reach or ever mark the rest read). const loadMore = () => { if (loadingMore || !cursor) return; setLoadingMore(true); getSystemNotifications(cursor).then(data => { setItems(list => list.concat(data.items || [])); setCursor(data.next_cursor || ''); }).catch(() => {}).finally(() => setLoadingMore(false)); }; const markRead = item => { if (item.read_at) return; setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i)); markSystemNotificationRead(item.id).catch(() => { // Roll the optimistic update back on failure -- otherwise the "new" badge silently vanishes // even though the server never actually recorded this notification as read. setItems(list => list.map(i => i.id === item.id ? {...i, read_at: null} : i)); }); }; return

系统消息

{loading ?

hourglass_empty加载中…

: null} {error ?

error_outline{error}

: null} {!loading && !error && items.length === 0 ?

notifications_none暂无系统消息

: null} {items.map(item =>
markRead(item)}>

{item.title}

{!item.read_at ? 新 : null}

{systemNotificationText(item)}

{item.action && item.action.device ?
设备:
{item.action.device}
{item.action.ip ?
IP地址:
{item.action.ip}
: null}
: null} {/* 入口按通知自身的 action 决定,不再对所有通知一律显示"前往安全设置"。注意 action.topic 不能直接当成群:好友申请类通知的 action 里带的是 p2p 会话 topic (db/mysql/tenant_adapter.go:8643),只有 grp 前缀才是真的群。都不匹配时不显示按钮, 避免把人导到跟这条通知无关的页面。 真实好友申请通知的 action 判断改用 event_type 而不是 action.type:实测 GET /system-notifications 拿到的 friend.request.created 通知,action 里只有 {user_id},从来没有 type 字段(当初写 action.type==='friend_request' 是凭对接文档 猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */} {item.action && typeof item.action.topic === 'string' && item.action.topic.startsWith('grp') ? : item.event_type === 'friend.request.created' ? : item.action && (item.action.device || item.action.ip) ? : null}
保持警惕
{BASE_APP_NAME}安全团队
)} {cursor ? : null}
setToast('')} />
; } export default function MessageCenterView(props) { switch (props.view) { case 'system-messages': return ; case 'message-actions': return ; case 'messages-offline': case 'messages-menu': case 'messages-home': default: return ; } }