第三轮深度测试:修复7个并行agent发现的前端问题,含REASON_MESSAGES系统性补全

派了Ch1-2/3-4/5-6/7/8/9/10-11共7个agent对全部11章重新深度测试。后端问题已核实追加进
docs/IM-后端问题清单-2026-09-04.md(新增6-9条,发给用户);本次是前端问题的修复。

**REASON_MESSAGES系统性补全(最高优先级,多个agent独立发现同一个缺口)**
im-api.js的REASON_MESSAGES表新增22个条目,最重要的是PERMISSION_DENIED——后端
server/im_http.go里403响应统一用这个reason(30余处调用),但表里原来只有一个从未
被后端实际发送过的死条目FORBIDDEN,导致全站几乎每一个403从工程一开始就在展示原始
英文"permission denied"。一并补上VERSION_CONFLICT/MALFORMED_REQUEST(Ch9 agent实测
命中)、NOT_FOUND、VERIFICATION_RATE_LIMITED(动态文案,同TOO_MANY_ATTEMPTS模式)等。
新增translateReason()导出函数并用于send-message.jsx的sendFavorite——favorites/forward
等几个批量接口恒定200、真实成败包在items[].reason里,imRequest的自动翻译够不到这种
per-item场景,之前直接显示原始reason字符串。tinode-web.jsx同理补了CTRL_ERROR_TEXT_CN
(原生WS {ctrl}协议错误,同样绕开REASON_MESSAGES)并修了原表从未真正匹配过的bug——
CommError会把状态码拼进message变成"authentication failed (401)",原表按裸文本精确
匹配,看着正常纯粹是因为401/403兜底文案凑巧一样,一旦命中"unknown authentication
scheme"这类会被兜底文案误导、"policy violation"这类会直接漏出英文。uploadLargeFile/
createChatUploader/createFileDownloader同样绕开REASON_MESSAGES(走原生{ctrl}协议),
补了UPLOAD_CTRL_TEXT_CN小词典。已用真实账号端到端验证:favorites/forward到P2P目标
触发的PERMISSION_DENIED现在正确显示"没有权限执行此操作";draft接口版本冲突正确显示
"内容已被修改,请刷新后重试"。

**编辑消息时打开附件面板会静默覆盖被编辑消息(Ch3-4 agent发现,数据损坏级别)**
sendMessage对editing状态一律用head.replace,但onAttachFile/onAttachImage/onAttachAudio
原来只在forwardMessage时置空,编辑态没有同款门禁——编辑一条消息不改文字、点"+"选张图片
或名片,会直接把附件内容替换掉被编辑消息的原文,无任何确认。messages-view.jsx新增
editingMessage门禁,和forwardMessage一样把三个attach回调置空(等价于隐藏整个"+"按钮)。
已用真实浏览器验证:编辑态下"+"按钮消失,取消编辑后正常恢复。

**草稿防抖单一共享定时器丢字(Ch1-2、Ch3-4两个agent独立发现)**
handleComposerChange用单个this.draftSaveTimer,话题A打字触发600ms保存后300ms内切到
话题B,会把A的定时器clearTimeout掉,那次输入永久丢失;version也在定时器触发时才重新
读state,此时可能已经在别的话题上,退化成硬编码0——叠加后端draft接口对version=0"无条件
跳过乐观锁"的行为(已记入后端清单新增6),可能静默覆盖别的标签页写入的更新草稿。改为
按话题分开的定时器map,version在调用的当下(保证仍在该话题)同步捕获进闭包。

**呼叫状态机两处真实bug(Ch8 agent此前发现,本次重新核实live代码后确认+修复)**
1. 通话中导航离开messages-view.jsx整个组件树(切到工作台/个人设置,或移动端切回会话
   列表)时,没有任何hangup清理——componentSetup已有的"切换话题时正确挂断"逻辑只在
   componentDidUpdate里,组件真正卸载时不会触发。CallPanel自己的componentWillUnmount
   只做本地清理,不通知对方也不重置tinode-web.jsx顶层callState,导致下次切回通话话题
   时remount出一个callState卡在CALL_STATE_IN_PROGRESS的"僵尸"面板——start()走"我们刚
   接听"分支直接return,从不调getUserMedia,本地媒体永久拿不到。补了对称的
   componentWillUnmount挂断逻辑。
2. this.callTimeoutTimer声明后从未被真正setTimeout赋值——呼出无人接听或accept信令
   (fire-and-forget的{note}包)被丢会永久卡在"呼叫中"。补了armCallRingTimeout(),
   60秒未接通按跟手动取消完全一样的路径挂断(不发明新的通话结果语义),在收到对方
   accept信号的两处分支都正确清timer。

**其余前端修复(按文件)**
- messages-view.jsx: @提醒mentionedUsers只在切话题时清空,同一话题内后续消息里字面
  文本"@张三"会被静默解析成真实提醒(发送后清空);jumpToMessage点开已撤回/删除的
  搜索结果原来只console.error静默失败,补了用户可见的错误提示;批量收藏对
  favorites/batch共享Idempotency-Key导致的静默丢失(已知后端问题)不再照单全收
  "收藏成功",改为发送后重新拉取真实收藏列表核对每条是否真的落地。
- audio-recorder.jsx: getUserMedia权限弹窗未决出结果时组件被卸载,补_unmounted
  标记避免在死实例上继续跑mediaRecorder.start()。
- send-message.jsx: disabled变为true时(对方拉黑/被禁言等实时ACS变化)没有强制结束
  正在进行的按住录音,可能remount出无用户手势的"自动录音";mouseup/touchend覆盖不到
  拖出窗口/切换标签页/权限弹窗抢焦点的释放场景,补了blur/visibilitychange兜底。
- tinode-web.jsx: messages-menu视图和messages-home共用MessageCenterView实例无key,
  从messages-home以非整体刷新方式切到messages-menu时菜单打不开,补key={view}。
- message-center-view.jsx: 实名认证驳回提醒的dismiss key只按用户+状态区分,同一状态
  的两次独立驳回会互相误压制,改按submission_id区分。
- contact-management-view.jsx: Profile/Settings靠getUserProfile刷新拉黑状态,但该
  接口对被拉黑对象直接403,导致"之前会话就已拉黑"的场景完全感知不到——叠加
  getConversationContext兜底(该接口对拉黑目标正确返回200+relationship)。
- group-management-view.jsx: AdminList显示群主/管理员姓名漏了group_nickname兜底;
  JoinRequests从聊天页横幅进入时返回按钮硬编码回group-settings(应回聊天本身),
  从Governance进入时返回group-settings而非group-governance(与AdminList/Muted不一致)。
- workplace-collaboration-view.jsx: OrganizationDirectory/DeptNode的部门树加载失败
  被无条件当成"暂无数据"渲染,补loadError区分+重试链接。
- personal-settings-view.jsx: CacheClear对navigator.storage/caches整个不存在的情况
  只有react出了范围的.catch()覆盖不到的同步抛错,加了特性检测+try/catch;
  YouthModeVerify触发TOO_MANY_ATTEMPTS锁定后确定按钮没有禁用/倒计时,会继续发出
  注定失败的请求。
- security-support-view.jsx: 意见反馈category字段在config未加载完成前可为空但没有
  提交前校验,会打到后端400。
- use-cached-data.js: bootstrap等首次拉取失败且无缓存兜底时永久停在undefined,加了
  一次3秒后重试(仅在完全没有缓存值时触发,已有缓存值维持原"显示旧数据"行为不变)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-04 23:29:52 +08:00
co-authored by Claude Sonnet 5
parent 505a6d7d56
commit 93b856aec8
20 changed files with 662 additions and 140 deletions
+12 -5
View File
@@ -257,11 +257,17 @@ function GroupChat({mode, onToast}) {
return <div className="group-page"><ChatHeader />{['group-new-request', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice" onClick={() => navigate('group-join-requests')}><Icon>person_add_alt</Icon><span><strong>新的进群申请</strong><small>2 条申请等待处理</small></span><Icon>chevron_right</Icon></button> : null}{['group-announcement', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice announcement" onClick={() => navigate('group-announcement-detail')}><Icon>campaign</Icon><span><strong>群公告</strong><small>下午三点进行版本评审,请准时参加</small></span><Icon>chevron_right</Icon></button> : null}<main className="group-chat-timeline"><p className="chat-time">今天 10:24</p>{mode === 'group-invite' ? <article className="group-invite-card"><div><Avatar item={GROUPS_DEMO.owned[0]} /><span><strong>产品研发群</strong><small>安妮邀请你加入群聊</small></span></div><p>一起参与新版产品的设计与研发讨论</p><button type="button" disabled={joined} onClick={() => {setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'}</button></article> : <><SystemEvent>安妮邀请栗子来了加入群聊</SystemEvent><SystemEvent>管理员通过了王珊珊的进群申请</SystemEvent><div className="group-chat-message"><Avatar item={MEMBERS[0]} /><div><span>安妮 <em>管理员</em></span><p>下午评审前请大家把问题同步到文档里。</p></div></div><div className="group-chat-message owner"><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><div><span>陈晨 <em>群主</em></span><p>收到,我会在两点前整理一版。</p></div></div></>}</main><div className="group-chat-composer"><button type="button"><Icon>record_voice_over</Icon></button><input placeholder="输入消息" /><button type="button"><Icon>sentiment_satisfied_alt</Icon></button><button type="button"><Icon>add_circle_outline</Icon></button></div></div>;
}
function JoinRequests({topic, onToast}) {
function JoinRequests({topic, fromChat, onToast}) {
// A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests)
// is a second entry point besides group-settings, and this whole feature remounts on every
// navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working
// through requests re-triggers this blank "加载中" flash on essentially every accept/reject.
// `fromChat`/`backToChat` mirror Announcement's handling of the exact same two-entry-point
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
// "group-settings" (closing the open conversation) instead of back to the chat itself.
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
const [profiles, setProfiles] = React.useState({});
const [loadError, setLoadError] = React.useState(false);
@@ -298,7 +304,8 @@ function JoinRequests({topic, onToast}) {
// to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong.
const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'};
return <div className="group-page">
<Header title="邀请进群申请" back="group-settings" backTopic={topic} />
<Header title="邀请进群申请" back={fromChat ? undefined : 'group-governance'} backTopic={topic}
onBack={fromChat ? backToChat : undefined} />
<main className="group-request-list">
{items === null ? <p className="group-inline-empty">{loadError ? '申请列表加载失败' : '加载中…'}</p> :
items.length ? items.map(item =>
@@ -701,11 +708,11 @@ function AdminList({topic, onToast}) {
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
<main className="group-admin-page">
<aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 10 名管理员。</p></aside>
{owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.display_name}</strong><small>群主</small></span></article></> : null}
{owner ? <><h2>群主</h2><article><Avatar item={owner} /><span><strong>{owner.group_nickname || owner.display_name}</strong><small>群主</small></span></article></> : null}
<h2>管理员 {admins ? admins.length : 0}/10</h2>
{admins === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
admins.length ? admins.map(item => <article key={item.user_id}>
<Avatar item={item} /><span><strong>{item.display_name}</strong><small>管理员</small></span>
<Avatar item={item} /><span><strong>{item.group_nickname || item.display_name}</strong><small>管理员</small></span>
<button type="button" onClick={() => remove(item.user_id)}>移除</button>
</article>) : <div className="group-inline-empty">暂无管理员</div>}
</main>
@@ -1062,7 +1069,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
let content;
if (view === 'group-list' || view === 'group-empty') content = <GroupList tinode={tinode} onToast={setToast} />;
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />;
else if (view === 'group-join-requests') content = <JoinRequests topic={topic} onToast={setToast} />;
else if (view === 'group-join-requests') content = <JoinRequests topic={topic} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
else if (view === 'group-announcement-detail') content = <Announcement topic={topic} myRole={myRole} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
else if (view === 'group-settings') content = <GroupSettings topic={topic} myUserId={myUserId} tinode={tinode} onToast={setToast} />;
else if (view === 'group-profile') content = <GroupProfile topic={topic} myUserId={myUserId} onToast={setToast} />;