第三轮深度测试:修复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:
co-authored by
Claude Sonnet 5
parent
505a6d7d56
commit
93b856aec8
+58
-7
@@ -30,7 +30,32 @@ const REASON_MESSAGES = {
|
||||
VALIDATION_FAILED: '提交的内容不符合要求',
|
||||
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
|
||||
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
|
||||
FORBIDDEN: '没有权限执行此操作',
|
||||
// 后端 server/im_http.go 里 30+ 处 403 用的都是 imReasonPermissionDenied="PERMISSION_DENIED",
|
||||
// 从未发送过字面量 "FORBIDDEN"——原来的 FORBIDDEN 条目是死代码,全站 403 一直透传英文原文。
|
||||
PERMISSION_DENIED: '没有权限执行此操作',
|
||||
NOT_FOUND: '内容不存在或已被删除',
|
||||
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
|
||||
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
|
||||
AUTH_REQUIRED: '请先登录',
|
||||
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
|
||||
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
|
||||
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
|
||||
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
|
||||
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
|
||||
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
|
||||
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
|
||||
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
|
||||
VERIFICATION_RATE_LIMITED: details => details && details.retry_after_seconds
|
||||
? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试`
|
||||
: '操作过于频繁,请稍后再试',
|
||||
ALREADY_GROUP_MEMBER: '对方已经是群成员',
|
||||
DESCRIPTION_REQUIRED: '请填写简介内容',
|
||||
DESCRIPTION_TOO_LONG: '简介内容过长',
|
||||
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
|
||||
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
|
||||
TOO_MANY_FILES: '文件数量超出限制',
|
||||
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
|
||||
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
|
||||
GROUP_MUTED: '你已被禁言,无法发言',
|
||||
@@ -130,14 +155,24 @@ export async function imRequest(path, {method = 'GET', body, headers, auth = tru
|
||||
// (not silently replaced with a generic string) since this backend's messages have consistently
|
||||
// been reasonable, specific, business-appropriate text in real testing, not raw internals -- only
|
||||
// reasons this app doesn't otherwise know how to react to fall through to it.
|
||||
const entry = payload.reason && REASON_MESSAGES[payload.reason];
|
||||
const known = typeof entry === 'function' ? entry(payload.details) : entry;
|
||||
const known = translateReason(payload.reason, payload.details);
|
||||
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
|
||||
payload.reason, response.status, payload.details);
|
||||
}
|
||||
return normalizeResponse(payload);
|
||||
}
|
||||
|
||||
// Several batch endpoints (favorites/batch, favorites/forward, messages/forward, ...) always return
|
||||
// HTTP 200 and bury the real per-item outcome in a nested `items[]`/`failed[]` array instead of
|
||||
// throwing -- imRequest's translation above never runs for those, since it only fires on a non-2xx/
|
||||
// non-zero-code response. Exported so call sites reading one of those per-item `reason` strings
|
||||
// directly (e.g. send-message.jsx's sendFavorite) can reuse the exact same table instead of either
|
||||
// duplicating it or -- as was actually happening -- showing the raw untranslated reason string.
|
||||
export function translateReason(reason, details) {
|
||||
const entry = reason && REASON_MESSAGES[reason];
|
||||
return (typeof entry === 'function' ? entry(details) : entry) || null;
|
||||
}
|
||||
|
||||
const idem = () => ({'Idempotency-Key': requestId()});
|
||||
|
||||
export const imGet = (path, opts) => imRequest(path, opts);
|
||||
@@ -199,6 +234,15 @@ export async function uploadPublicFile(file, purpose, onProgress) {
|
||||
// Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope --
|
||||
// there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its
|
||||
// extension suffix) since getFileUrl() already round-trips that exact format unmodified.
|
||||
// This upload talks to Tinode's native {ctrl} protocol (see the comment above), not the REST layer,
|
||||
// so REASON_MESSAGES above never applies to it -- confirmed against server/datamodel.go's Err*
|
||||
// constructors for the literal ctrl.text values a failure here can actually carry.
|
||||
const UPLOAD_CTRL_TEXT_CN = {
|
||||
'too large': '文件大小超出限制',
|
||||
'malformed': '请求内容有误,请稍后重试',
|
||||
'permission denied': '没有权限执行此操作',
|
||||
'gone': '内容不存在或已被删除'
|
||||
};
|
||||
export function uploadLargeFile(tinode, file, onProgress) {
|
||||
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
|
||||
if (!authToken) {
|
||||
@@ -223,7 +267,8 @@ export function uploadLargeFile(tinode, file, onProgress) {
|
||||
const url = ctrl.params.url;
|
||||
resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires});
|
||||
} else {
|
||||
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
|
||||
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
|
||||
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
|
||||
@@ -273,7 +318,11 @@ export function createChatUploader(tinode) {
|
||||
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
|
||||
resolve(ctrl.params.url);
|
||||
} else {
|
||||
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
|
||||
// Same native-{ctrl}-protocol translation as uploadLargeFile above -- REASON_MESSAGES
|
||||
// never applies here, and a too-large photo/video/file picked from the attachment panel
|
||||
// is a realistic, easy way for a real user to hit this.
|
||||
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
|
||||
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
|
||||
@@ -327,7 +376,9 @@ export function createFileDownloader(tinode) {
|
||||
let err;
|
||||
try {
|
||||
const pkt = JSON.parse(this.result);
|
||||
err = new ImApiError((pkt.ctrl && pkt.ctrl.text) || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
|
||||
const text = pkt.ctrl && pkt.ctrl.text;
|
||||
const known = text && UPLOAD_CTRL_TEXT_CN[text.trim().toLowerCase()];
|
||||
err = new ImApiError(known || text || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
|
||||
} catch (e) {
|
||||
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
|
||||
}
|
||||
@@ -499,7 +550,7 @@ export const forwardMessages = (mode, sources, targetTopics, comment = '') =>
|
||||
imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment});
|
||||
export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor}));
|
||||
export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))});
|
||||
export const getFavorites = ({type = 'all', query = '', cursor = ''} = {}) => imGet('/favorites' + qs({type, q: query, cursor}));
|
||||
export const getFavorites = ({type = 'all', query = '', cursor = '', limit} = {}) => imGet('/favorites' + qs({type, q: query, cursor, limit}));
|
||||
export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
|
||||
export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
|
||||
export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds});
|
||||
|
||||
@@ -23,19 +23,34 @@ export function useCachedData(key, fetcher, deps) {
|
||||
React.useEffect(() => {
|
||||
if (!key) return;
|
||||
let live = true;
|
||||
let retried = false;
|
||||
// Don't flip back to a loading state just because deps changed while we already have a cached
|
||||
// value for the new key -- only the truly-never-fetched case should show a loading affordance.
|
||||
setLoading(!cache.has(key));
|
||||
fetcher().then(result => {
|
||||
const attempt = () => fetcher().then(result => {
|
||||
if (!live) return;
|
||||
cache.set(key, result);
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
if (!live) return;
|
||||
// A transient failure on the very *first* fetch for this key -- nothing cached yet to fall
|
||||
// back on -- used to leave `data` stuck at undefined for the rest of the session, since
|
||||
// nothing else ever re-triggers this effect unless `key`/`deps` change. One retry after a
|
||||
// short delay covers the common case (a network blip during initial page load) without
|
||||
// retry-storming a persistently failing endpoint. A key that already has *some* cached value
|
||||
// keeps the original behavior (show the stale value, don't retry) -- that case is already
|
||||
// showing the user something, so it's lower urgency than the stuck-at-nothing case.
|
||||
if (!retried && !cache.has(key)) {
|
||||
retried = true;
|
||||
window.setTimeout(() => { if (live) attempt(); }, 3000);
|
||||
return;
|
||||
}
|
||||
// Keep whatever stale/cached value was already showing rather than clearing it on a transient
|
||||
// failure -- a blank screen on error is worse than a slightly-out-of-date one.
|
||||
if (live) setLoading(false);
|
||||
setLoading(false);
|
||||
});
|
||||
attempt();
|
||||
return () => { live = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, ...(deps || [])]);
|
||||
|
||||
Reference in New Issue
Block a user