第五轮深度测试:管理员撤回、所有人@提醒、失效附件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:
co-authored by
Claude Sonnet 5
parent
4a30cfa6b3
commit
d42591284f
@@ -7,7 +7,7 @@ import {
|
||||
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
|
||||
updateMyGroupProfile, getMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
|
||||
acceptGroupJoinRequest, rejectGroupJoinRequest, getLatestGroupAnnouncement,
|
||||
updateCurrentGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
|
||||
updateCurrentGroupAnnouncement, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
|
||||
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
|
||||
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
|
||||
getGroupMemberProfile
|
||||
@@ -363,6 +363,16 @@ function Announcement({topic, myRole, fromChat, onToast}) {
|
||||
setData(d); setEditing(false); onToast('群公告已发布');
|
||||
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
|
||||
};
|
||||
// 6.9 要求"记录已读状态",但这个接口(POST .../read)之前完全没有调用点——每次打开公告详情页
|
||||
// (无论从群设置点进来还是从聊天顶部横幅点进来)都视为"已读",用ref去重避免同一条公告在同一次
|
||||
// 页面停留内重复上报。静默失败:已读打点不是这个页面的主要功能,不应该在失败时打断用户。
|
||||
const readReportedFor = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const id = data && data.announcement_id;
|
||||
if (!id || !topic || readReportedFor.current === id) return;
|
||||
readReportedFor.current = id;
|
||||
readGroupAnnouncement(topic, id).catch(() => {});
|
||||
}, [topic, data]);
|
||||
if (!data) return <div className="group-page"><Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
|
||||
return <div className="group-page">
|
||||
<Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined}
|
||||
@@ -640,7 +650,12 @@ function Governance({topic, tinode, onToast}) {
|
||||
getGroupGovernance(topic).then(d => { writeCachedData('group-governance:' + topic, d); setGov(d); }).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic]);
|
||||
const setField = (key, value) => {
|
||||
updateGroupGovernance(topic, {[key]: value}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
|
||||
// GET .../governance does return a real `version` (confirmed live) -- omitting it here isn't
|
||||
// just "no version sent", the server-side UPDATE for this endpoint skips its optimistic-lock
|
||||
// WHERE clause entirely whenever version<=0, so two admins toggling different switches at
|
||||
// nearly the same time would have the second write silently clobber the first with no conflict
|
||||
// signal. Sending the version this page already has on hand costs nothing and closes that gap.
|
||||
updateGroupGovernance(topic, {[key]: value, version: gov ? gov.version : 0}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
|
||||
.catch(err => onToast(err.message || '设置失败'));
|
||||
};
|
||||
const dissolve = () => {
|
||||
@@ -860,7 +875,13 @@ function OwnerSelect({topic, onToast}) {
|
||||
if (!password) { onToast('请输入密码以确认'); return; }
|
||||
if (busy || !target) return;
|
||||
setBusy(true);
|
||||
transferGroupOwner(topic, target.user_id).then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
|
||||
// transferGroupOwner's `version` param was never supplied here, so the optimistic-lock WHERE
|
||||
// clause on the backend's UPDATE was silently skipped (version<=0) -- this is the single most
|
||||
// consequential owner-transfer call in the app having zero protection against a concurrent
|
||||
// transfer. Fetch the live group version right before the irreversible call rather than trusting
|
||||
// any value this page might have cached earlier in its lifetime.
|
||||
getGroup(topic).then(group => transferGroupOwner(topic, target.user_id, group.version))
|
||||
.then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
|
||||
.catch(err => onToast(err.message || '转让失败')).finally(() => setBusy(false));
|
||||
};
|
||||
const row = item => <article key={item.user_id} role="button" tabIndex={0} onClick={() => setTarget(item)}>
|
||||
@@ -898,11 +919,8 @@ const FILE_TYPE_META = {
|
||||
};
|
||||
|
||||
function GroupFiles({topic, tinode, onToast}) {
|
||||
// 群文件搜索走服务端 getGroupFiles({query}),同样防抖。
|
||||
const [queryInput, setQueryInput] = React.useState('');
|
||||
const query = useDebouncedValue(queryInput, 300);
|
||||
// Only the unfiltered (query === '') result is cached -- same rule GroupList already follows, so a
|
||||
// plain revisit never shows a stale search result under a fresh empty query.
|
||||
const [files, setFiles] = React.useState(() => (topic && readCachedData('group-files:' + topic)) || null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [uploading, setUploading] = React.useState(null);
|
||||
@@ -911,14 +929,19 @@ function GroupFiles({topic, tinode, onToast}) {
|
||||
// 显示在"来自"位置。跟 Muted 页一样,拉一次成员表交叉引用出可读的名字。
|
||||
const [uploaderNames, setUploaderNames] = React.useState({});
|
||||
const inputRefs = {file: React.useRef(null), image: React.useRef(null), video: React.useRef(null)};
|
||||
// 搜索框文案承诺"搜索文件名或者上传者",但服务端 ListFiles 的 `q` 只匹配文件名(上传者昵称
|
||||
// 这类展示用的名字根本不在服务端索引里,是前端自己拿 getGroupMembers 交叉查出来的)——如果把
|
||||
// query 传给服务端,按上传者名字搜索永远是静默的空结果。改成服务端只拿全量列表(本来文件数量
|
||||
// 就不大,也没有任何"加载更多"分页UI),文件名/上传者两个维度都在本地用已有的 uploaderNames
|
||||
// 表一起过滤,让搜索框真正兑现自己的文案。
|
||||
const load = React.useCallback(() => {
|
||||
if (!topic) return;
|
||||
getGroupFiles(topic, {query}).then(data => {
|
||||
getGroupFiles(topic, {}).then(data => {
|
||||
const items = data.items || [];
|
||||
if (!query) writeCachedData('group-files:' + topic, items);
|
||||
writeCachedData('group-files:' + topic, items);
|
||||
setFiles(items);
|
||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
}, [topic, query]);
|
||||
}, [topic]);
|
||||
React.useEffect(() => { load(); }, [load]);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
@@ -928,6 +951,13 @@ function GroupFiles({topic, tinode, onToast}) {
|
||||
setUploaderNames(map);
|
||||
}).catch(() => {});
|
||||
}, [topic]);
|
||||
const visibleFiles = React.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); inputRefs[kind].current && inputRefs[kind].current.click(); };
|
||||
const onFile = e => {
|
||||
@@ -963,7 +993,8 @@ function GroupFiles({topic, tinode, onToast}) {
|
||||
<div><strong>{uploading.name}</strong><small>上传中 {uploading.progress}%</small><progress value={uploading.progress} max="100" /></div>
|
||||
</article> : null}
|
||||
{files === null ? <p className="group-inline-empty">{loadError ? '文件列表加载失败' : '加载中…'}</p> :
|
||||
files.map(file => {
|
||||
visibleFiles.length === 0 && query.trim() ? <p className="group-inline-empty">未找到匹配的文件</p> :
|
||||
visibleFiles.map(file => {
|
||||
const [icon, color] = FILE_TYPE_META[file.file_type] || FILE_TYPE_META.file;
|
||||
return <article key={file.group_file_id}>
|
||||
<span className="group-file-type" style={{backgroundColor: color}}><Icon>{icon}</Icon></span>
|
||||
|
||||
Reference in New Issue
Block a user