feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -39,6 +39,14 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
|
||||
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
|
||||
PERMISSION_DENIED: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2629's own canonical "must implement" list -- distinct from
|
||||
// PERMISSION_DENIED above (a separately-observed live reason, kept as-is). Missing this one meant
|
||||
// imRequest's fallback chain (`payload.message || payload.reason || ...`) leaked the server's raw
|
||||
// internal message or the bare reason string straight to the user for this exact reason code,
|
||||
// which the doc explicitly says must never happen (§0.4/§12.4: "不得把服务端 message 原样展示给用户").
|
||||
FORBIDDEN: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2312 -- ProfileSettingsPage's birth_date save can hit this.
|
||||
PROFILE_FIELD_INVALID: '资料字段格式不正确,请检查后重试',
|
||||
NOT_FOUND: '内容不存在或已被删除',
|
||||
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
|
||||
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
|
||||
@@ -59,22 +67,28 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
|
||||
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
|
||||
APPLICATION_DUPLICATED: '该企业已有申请正在审核中,请耐心等待',
|
||||
TOO_MANY_FILES: '文件数量超出限制',
|
||||
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
|
||||
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
|
||||
GROUP_MUTED: '你已被禁言,无法发言',
|
||||
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
|
||||
GROUP_VERSION_CONFLICT: '群信息已被修改,请刷新后重试',
|
||||
INVITATION_EXPIRED: '邀请已过期',
|
||||
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
|
||||
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
|
||||
FILE_TOO_LARGE: '文件大小超出限制',
|
||||
FILE_FORBIDDEN: '没有权限访问此文件',
|
||||
FILE_UNAVAILABLE: '文件暂不可用',
|
||||
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
|
||||
REALNAME_REQUIRED: '需要先完成实名认证',
|
||||
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
|
||||
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
|
||||
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
|
||||
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
|
||||
QR_CODE_INVALID: '二维码无效,请重新扫描',
|
||||
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
|
||||
USER_NOT_DISCOVERABLE: '未找到该用户',
|
||||
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
|
||||
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
|
||||
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
|
||||
// verification-code verify) -- keep generic enough to be accurate for both.
|
||||
CODE_INVALID: '验证码或独立密码不正确',
|
||||
@@ -167,6 +181,11 @@ export async function imRequest<T = unknown>(
|
||||
}
|
||||
if (!response.ok || payload?.code) {
|
||||
const known = translateReason(payload.reason, payload.details);
|
||||
// A 401 on a request that DID carry a Bearer token means the server has stopped honoring that
|
||||
// token (expired/revoked while a WS reconnect or a stale tab kept using it) -- nothing anywhere
|
||||
// else in this client uniformly handled this before, so a session invalidated mid-use just made
|
||||
// every subsequent call fail the same way forever with no path back to a real login.
|
||||
if (auth && response.status === 401 && authToken()) forceReauth();
|
||||
throw new ImApiError(
|
||||
known || payload.message || payload.reason || response.statusText || '请求失败',
|
||||
payload.reason,
|
||||
@@ -177,6 +196,30 @@ export async function imRequest<T = unknown>(
|
||||
return normalizeResponse(payload) as T;
|
||||
}
|
||||
|
||||
/** Clears the invalidated session and hard-navigates to /login -- deliberately a full page load
|
||||
* (not an in-SPA redirect) so every piece of in-memory state (the Tinode singleton, TanStack Query
|
||||
* cache, any open modal's local state) resets from scratch rather than needing each of them to
|
||||
* separately react correctly to "you were logged in a second ago, now you're not." Guarded so a
|
||||
* burst of parallel requests failing at once (a realistic case: several queries in flight when the
|
||||
* token expires) only navigates once. */
|
||||
let reauthTriggered = false;
|
||||
function forceReauth() {
|
||||
if (reauthTriggered) return;
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
// The latch only needs to survive until the hard reload below actually happens -- that reload is
|
||||
// what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the
|
||||
// latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background
|
||||
// request 401-ing while already sitting on `/login` (reachable via an in-SPA `<Navigate>`, which
|
||||
// never reloads) burned the latch with no reload to reset it -- silently disabling this function
|
||||
// for the rest of that tab's life, including a genuine expiry after the user logs back in on the
|
||||
// same SPA instance.
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
reauthTriggered = true;
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
||||
|
||||
// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
|
||||
|
||||
Reference in New Issue
Block a user