第五轮深度测试:管理员撤回、所有人@提醒、失效附件UI,并核对现存后端问题清单

- messages-view.jsx: 拖拽发送/回复菜单在编辑态下的旁路修复;群主/管理员可撤回他人消息
  (对齐后端 authorizeIMMessageMutation 的 allowAdmin);新增"所有人"@提醒(head.mention_all)。
- chat-message.jsx: system_action 头类型消息按 system_notice 同款渲染(此前显示"内容无效")。
- content-library-view.jsx: 语音/链接/图文收藏与聊天记录卡片补上 removed(已失效)态UI,
  之前只有 FileCard 处理了这个状态。
- call-panel.jsx: handleVideoOfferMsg(5步.then链,用reject sentinel跳过后续步骤)、
  handleToggleSpeakerClick 补组件卸载守卫,修完此前遗漏的两处。
- group-management-view.jsx: 治理开关(Governance.setField)和群主转让(OwnerSelect)
  补上乐观锁 version 参数(此前从不传,服务端 version<=0 时会跳过冲突检测);群文件搜索框
  文案承诺"搜索文件名或者上传者"但服务端q参数只匹配文件名,改为拉取全量列表本地按文件名+
  已解析的上传者昵称一起过滤;群公告详情页补上从未被调用过的已读上报(readGroupAnnouncement)。

以上均用真实账号在浏览器/直接请求验证:管理员撤回、system_action渲染、mention_all、
文件按上传者搜索、公告已读上报(200)、治理开关stale-version探测确认409冲突检测生效。

7个并行测试agent对全部11章做了第二轮回归+现存后端问题清单逐条复核,结论待整理进
docs/IM-后端问题清单*.md(全部现存条目复核后均建议保留,多条证据强度从推断升级为已验证)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-05 02:07:47 +08:00
co-authored by Claude Sonnet 5
parent 4a30cfa6b3
commit d42591284f
13 changed files with 295 additions and 63 deletions
+23 -2
View File
@@ -16,6 +16,8 @@ const DIALING_SOUND = new Audio('audio/dialing.m4a');
const VIDEO_MUTED_EVENT = 'video:muted';
const VIDEO_UNMUTED_EVENT = 'video:unmuted';
// See handleVideoOfferMsg's use of this below.
const UNMOUNTED_DURING_OFFER = Symbol('unmounted-during-offer');
function formatElapsedTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
@@ -571,6 +573,19 @@ class CallPanel extends React.PureComponent {
return navigator.mediaDevices.getUserMedia(this.localStreamConstraints);
})
.then(stream => {
// Same permission-prompt-outlives-the-call race as start()/unmuteVideo()/
// handleSwitchCameraClick() -- this is the callee-side counterpart (answering an offer),
// a separate getUserMedia call this round's fix missed: start() takes an early-return
// branch for an already-in-progress call and never reaches its own getUserMedia here.
// A bare `return` wouldn't be enough on its own to bail out of the rest of *this* chain
// (createAnswer/setLocalDescription/onSendAnswer below still run on the next tick with
// `undefined`) -- rejecting with the sentinel skips straight past all of those (none of
// them pass an onRejected handler to .then()) to the .catch() at the end, which checks for
// it explicitly so this cancellation isn't reported as a real getUserMedia failure.
if (this._unmounted) {
stream.getTracks().forEach(t => t.stop());
return Promise.reject(UNMOUNTED_DURING_OFFER);
}
let dummyVideo;
if (!this.localStreamConstraints.video) {
// Starting an audio-only call. Create an empty video track so
@@ -602,7 +617,7 @@ class CallPanel extends React.PureComponent {
this.props.onSendAnswer(this.props.topic, this.props.seq, pc.localDescription.toJSON());
this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache());
})
.catch(this.handleGetUserMediaError);
.catch(err => { if (err !== UNMOUNTED_DURING_OFFER) { this.handleGetUserMediaError(err); } });
}
// Call disconnected by remote.
@@ -781,9 +796,15 @@ class CallPanel extends React.PureComponent {
const match = nextOn ?
outputs.find(d => /speaker/i.test(d.label)) :
outputs.find(d => /earpiece|receiver/i.test(d.label));
// Same unmount race as the gUM-based methods -- enumerateDevices can still be pending
// after the call ends. Currently silent either way (the .catch below only logs), so this
// just avoids a pointless TypeError in the console rather than fixing a visible symptom.
if (this._unmounted || !this.remoteRef.current) {
return null;
}
return this.remoteRef.current.setSinkId(match ? match.deviceId : '');
})
.then(_ => this.setState({speakerOn: nextOn}))
.then(_ => { if (!this._unmounted) { this.setState({speakerOn: nextOn}); } })
.catch(err => console.error('Failed to switch audio output device', err));
}