第五轮深度测试:管理员撤回、所有人@提醒、失效附件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
+71 -18
View File
@@ -9032,13 +9032,24 @@ function FileCard({
}
function VoiceCard({
att,
head
head,
removed
}) {
const [src, setSrc] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
resolveAttachmentUrl(att).then(setSrc);
}, [att]);
if (!removed) resolveAttachmentUrl(att).then(setSrc);
}, [att, removed]);
const duration = att && att.duration || head && head.duration || 0;
if (removed) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
className: "library-voice-card removed",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Icon, {
children: "block"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("em", {
children: "语音已失效"
}, void 0, false)]
}, void 0, true);
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
className: "library-voice-card",
children: src ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(_widgets_audio_player_jsx__WEBPACK_IMPORTED_MODULE_2__["default"], {
@@ -9053,8 +9064,19 @@ function VoiceCard({
}
function LinkText({
url,
onOpen
onOpen,
removed
}) {
if (removed) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
className: "library-link-text removed",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Icon, {
children: "link_off"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("em", {
children: "链接已失效"
}, void 0, false)]
}, void 0, true);
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
className: "library-link-text",
@@ -9070,8 +9092,19 @@ function MediaThumb({
url,
video,
duration,
onOpen
onOpen,
removed
}) {
if (removed) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
className: "library-media-thumb removed",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Icon, {
children: video ? 'videocam_off' : 'broken_image'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("em", {
children: video ? '视频已失效' : '图片已失效'
}, void 0, false)]
}, void 0, true);
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
className: "library-media-thumb",
@@ -9122,14 +9155,17 @@ function FavoriteItem({
onOpen: onOpen
}, void 0, false) : null, item.type === 'voice' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(VoiceCard, {
att: att,
head: item.snapshot && item.snapshot.head
head: item.snapshot && item.snapshot.head,
removed: item.availability === 'removed'
}, void 0, false) : null, item.type === 'link' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(LinkText, {
url: item.snapshot && item.snapshot.content && item.snapshot.content.url || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text),
onOpen: onOpen
onOpen: onOpen,
removed: item.availability === 'removed'
}, void 0, false) : null, item.type === 'image' || item.type === 'video' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(MediaThumb, {
url: item._thumbUrl,
video: item.type === 'video',
onOpen: onOpen
onOpen: onOpen,
removed: item.availability === 'removed'
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("footer", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("span", {
children: ["来自 ", source]
@@ -9802,7 +9838,8 @@ function History({
fmt: item.fmt,
ent: item.ent
} : undefined, item.text),
onOpen: openLink
onOpen: openLink,
removed: item.availability === 'removed'
}, void 0, false)]
}, item.seq, true))
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("section", {
@@ -9811,7 +9848,8 @@ function History({
url: mediaUrls[item.seq],
video: view === 'history-videos',
duration: item.duration,
onOpen: () => jump(item.seq)
onOpen: () => jump(item.seq),
removed: item.availability === 'removed'
}, item.seq, false))
}, void 0, false), hasMore ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
@@ -11257,6 +11295,13 @@ function Announcement({
onToast('群公告已发布');
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
};
const readReportedFor = react__WEBPACK_IMPORTED_MODULE_0___default().useRef(null);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
const id = data && data.announcement_id;
if (!id || !topic || readReportedFor.current === id) return;
readReportedFor.current = id;
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.readGroupAnnouncement)(topic, id).catch(() => {});
}, [topic, data]);
if (!data) return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "group-page",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Header, {
@@ -11801,7 +11846,8 @@ function Governance({
}, [topic]);
const setField = (key, value) => {
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.updateGroupGovernance)(topic, {
[key]: value
[key]: value,
version: gov ? gov.version : 0
}).then(data => {
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-governance:' + topic, data);
setGov(data);
@@ -12214,7 +12260,7 @@ function OwnerSelect({
}
if (busy || !target) return;
setBusy(true);
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.transferGroupOwner)(topic, target.user_id).then(() => {
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroup)(topic).then(group => (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.transferGroupOwner)(topic, target.user_id, group.version)).then(() => {
onToast('群主已转让');
navigate('group-governance', topic);
}).catch(err => onToast(err.message || '转让失败')).finally(() => setBusy(false));
@@ -12334,17 +12380,15 @@ function GroupFiles({
};
const load = react__WEBPACK_IMPORTED_MODULE_0___default().useCallback(() => {
if (!topic) return;
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupFiles)(topic, {
query
}).then(data => {
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupFiles)(topic, {}).then(data => {
const items = data.items || [];
if (!query) (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-files:' + topic, items);
(0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.writeCachedData)('group-files:' + topic, items);
setFiles(items);
}).catch(() => {
onToast('加载失败');
setLoadError(true);
});
}, [topic, query]);
}, [topic]);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
load();
}, [load]);
@@ -12358,6 +12402,12 @@ function GroupFiles({
setUploaderNames(map);
}).catch(() => {});
}, [topic]);
const visibleFiles = react__WEBPACK_IMPORTED_MODULE_0___default().useMemo(() => {
const all = files || [];
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter(f => (f.file_name || '').toLowerCase().includes(q) || (uploaderNames[f.uploader_id] || '').toLowerCase().includes(q));
}, [files, query, uploaderNames]);
const usedBytes = (files || []).reduce((sum, f) => sum + (f.file_size || 0), 0);
const pick = kind => {
setMenuOpen(false);
@@ -12479,7 +12529,10 @@ function GroupFiles({
}, void 0, true) : null, files === null ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
className: "group-inline-empty",
children: loadError ? '文件列表加载失败' : '加载中…'
}, void 0, false) : files.map(file => {
}, void 0, false) : visibleFiles.length === 0 && query.trim() ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
className: "group-inline-empty",
children: "未找到匹配的文件"
}, void 0, false) : visibleFiles.map(file => {
const [icon, color] = FILE_TYPE_META[file.file_type] || FILE_TYPE_META.file;
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("article", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
+1 -1
View File
File diff suppressed because one or more lines are too long
+28 -3
View File
@@ -205,6 +205,7 @@ function isPeerRestricted(acs) {
}
return false;
}
const MENTION_ALL_UID = '__mention_all__';
function shouldPresentCallPanel(callState) {
return callState == _constants_js__WEBPACK_IMPORTED_MODULE_16__.CALL_STATE_OUTGOING_INITATED || callState == _constants_js__WEBPACK_IMPORTED_MODULE_16__.CALL_STATE_IN_PROGRESS;
}
@@ -216,6 +217,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
this.state.toast = null;
this.state.riskDialog = null;
this.mentionedUsers = [];
this.mentionAllPending = false;
this.componentSetup = this.componentSetup.bind(this);
this.leave = this.leave.bind(this);
this.sendMessage = this.sendMessage.bind(this);
@@ -389,6 +391,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
topic.onAuxUpdated = this.handleAuxUpdate;
}
this.mentionedUsers = [];
this.mentionAllPending = false;
this.loadRelationshipContext(topic);
this.loadGroupContext(topic);
this.loadDraft(topic);
@@ -1022,7 +1025,8 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}
menuItems.push('menu_item_copy', 'menu_item_favorite');
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
if (params.userFrom == this.props.myUserId && params.seq > 0 && recallWindow > 0 && params.timestamp && params.timestamp.getTime() > new Date().getTime() - recallWindow * 1000) {
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && recallWindow > 0 && params.timestamp && params.timestamp.getTime() > new Date().getTime() - recallWindow * 1000) {
menuItems.push('menu_item_recall');
}
if (topic.isSelfType()) {
@@ -1110,9 +1114,23 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
return (0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.getGroupMembers)(this.state.topic, {
mentionable: true,
query
}).then(data => data.items || []);
}).then(data => {
const items = data.items || [];
const canMentionAll = !!(data.permissions && data.permissions.can_mention_all);
if (canMentionAll && '所有人'.includes((query || '').trim())) {
return [{
user_id: MENTION_ALL_UID,
display_name: '所有人'
}, ...items];
}
return items;
});
}
mentionSelected(uid, name) {
if (uid === MENTION_ALL_UID) {
this.mentionAllPending = true;
return;
}
this.mentionedUsers = (this.mentionedUsers || []).filter(m => m.name !== name);
this.mentionedUsers.push({
uid,
@@ -1297,6 +1315,13 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
msg = parsed;
}
this.mentionedUsers = [];
if (this.mentionAllPending) {
head = {
...(head || {}),
mention_all: true
};
this.mentionAllPending = false;
}
if (typeHint) {
head = {
...(head || {}),
@@ -2741,7 +2766,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
let textSizeClass = 'message-content';
const fullDisplay = this.props.isGroup && this.props.response && (this.props.sequence == 'single' || this.props.sequence == 'last');
let content = this.props.content;
if (this.props.head && this.props.head['x-im-type'] === 'system_notice' && content && typeof content == 'object' && typeof content.text == 'string') {
if (this.props.head && (this.props.head['x-im-type'] === 'system_notice' || this.props.head['x-im-type'] === 'system_action') && content && typeof content == 'object' && typeof content.text == 'string') {
content = content.text;
}
let bundleCard = null;
File diff suppressed because one or more lines are too long
+20 -4
View File
@@ -31,6 +31,7 @@ CALL_ENDED_SOUND.loop = true;
const DIALING_SOUND = new Audio('audio/dialing.m4a');
const VIDEO_MUTED_EVENT = 'video:muted';
const VIDEO_UNMUTED_EVENT = 'video:unmuted';
const UNMOUNTED_DURING_OFFER = Symbol('unmounted-during-offer');
function formatElapsedTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor(totalSeconds % 3600 / 60);
@@ -498,6 +499,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
pc.setRemoteDescription(desc).then(_ => {
return navigator.mediaDevices.getUserMedia(this.localStreamConstraints);
}).then(stream => {
if (this._unmounted) {
stream.getTracks().forEach(t => t.stop());
return Promise.reject(UNMOUNTED_DURING_OFFER);
}
let dummyVideo;
if (!this.localStreamConstraints.video) {
dummyVideo = this.emptyVideoTrack();
@@ -525,7 +530,11 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.setState({
callInitialSetupComplete: true
}, _ => this.drainRemoteIceCandidatesCache());
}).catch(this.handleGetUserMediaError);
}).catch(err => {
if (err !== UNMOUNTED_DURING_OFFER) {
this.handleGetUserMediaError(err);
}
});
}
handleRemoteHangup() {
if (!this.state.waitingForPeer) {
@@ -685,10 +694,17 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
navigator.mediaDevices.enumerateDevices().then(devices => {
const outputs = devices.filter(d => d.kind == 'audiooutput');
const match = nextOn ? outputs.find(d => /speaker/i.test(d.label)) : outputs.find(d => /earpiece|receiver/i.test(d.label));
if (this._unmounted || !this.remoteRef.current) {
return null;
}
return this.remoteRef.current.setSinkId(match ? match.deviceId : '');
}).then(_ => this.setState({
speakerOn: nextOn
})).catch(err => console.error('Failed to switch audio output device', err));
}).then(_ => {
if (!this._unmounted) {
this.setState({
speakerOn: nextOn
});
}
}).catch(err => console.error('Failed to switch audio output device', err));
}
render() {
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
File diff suppressed because one or more lines are too long