Files
im_webapp/web/src/features/settings/SettingsLayout.tsx
T
Your NameandClaude Sonnet 5 ab96ae1fee fix(web): 第9轮审查批次1——认证/图标CSS/顶号竞态/消息核心一批问题
认证域:
- 找回密码验证码请求存在竞态:请求发出后如果用户改了手机号,旧请求
  返回时仍会用旧手机号的challenge�covers新手机号的输入框,现在按
  发起请求时的手机号快照判断,作废过期响应
- 切换企业码时"退出登录"确认框可被绕过:判断依据只有内存里的
  myUserId,直接刷新在/tenant页会让这个值变回null,导致确认框不弹
  就静默销毁一个其实还有效的会话;现在同时检查sessionStorage里的
  token
- 企业码不存在错误文案缺后半句",请核实后重试",和其它两处文案对齐
- 营业执照上传补上前端体积/类型校验(仿照问题反馈页截图校验的写法),
  不再无条件发一次注定被拒的上传请求
- 找回密码页移动端"重置密码"标题重复渲染两次,桌面版标题补上
  min-[900px]:block hidden
- 企业码输入框补maxLength=64

图标CSS:Material Icons的.im-icon规则漏抄了Google原始样式表的两行
(-webkit-font-feature-settings/-webkit-font-smoothing),补齐让
"1:1 copy"真正成立

顶号踢人 vs 已有401登出的竞态:两条路径会同时清空同一个token,如果
401先触发,用户会被直接甩回登录页看不到"账号已在其他设备登录"的
提示——forceReauth()现在先等一小段时间看kickedInfo是否被顶号流程
设置,设置了就让顶号流程自己的按钮触发跳转,避免抢跑

通话信令健壮性:sendCallRinging/Accept/Hangup底层用的{note}包没有id
字段,连接断开时会同步抛异常而不是走Promise reject——之前hangUp()
在这类异常后直接中断,导致后续的leaveCall()清理和UI收尾全部被跳过。
统一加了try/catch兜底

消息核心:
- 名片消息/通话结果消息(非Drafty的扁平自定义对象)之前可以被引用/
  复制/转发,会把这些对象错误地喂给Drafty的解析函数,产生格式错误
  的内容——现在这两类消息只保留收藏/撤回/删除/多选
- MessagesLayout.tsx自己独立计算的peerName没有对slf(自聊天)做
  isSelf特殊处理,会显示裸topic id"slf"而不是昵称,和已经修过的
  ConversationView头部不一致,现在补齐同样的处理
- 发送失败图标/文案错用了danger-text(撤回删除等破坏性操作的语义
  token),改成语义正确的unread token;录音条文字被连带染红,只保留
  指示点是红色
- 群成员数徽标形状(应为4px圆角矩形不是胶囊)和颜色(应为text-tertiary-4
  不是text-tertiary-3)都和设计稿不符,已修正

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:33:23 +08:00

239 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { logout as revokeServerSession } from '@/lib/api/auth';
import { getMe } from '@/lib/api/profile';
import { resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { notImplemented } from '@/stores/globalToast';
import { useSessionStore } from '@/stores/session';
const DIRECTORY = [
{ key: 'profile', to: '/settings/profile', icon: 'account_circle', label: '个人资料' },
{ key: 'security', to: '/settings/security', icon: 'security', label: '账号与安全' },
{ key: 'notifications', to: '/settings/notifications', icon: 'notifications', label: '消息通知' },
{ key: 'privacy', to: '/settings/privacy', icon: 'lock', label: '隐私' },
{ key: 'general', to: '/settings/general', icon: 'tune', label: '通用' },
];
/**
* README §8.7 (mobile "我的" page): 我的收藏/通话记录 are grouped ABOVE the regular settings
* directory. Desktop never needs this list -- those already have their own permanent NavRail icons
* there -- but on a phone-width viewport (bottom tab bar instead of a rail) there is otherwise NO
* way to reach `/favorites` or `/calls` at all short of typing the URL directly (confirmed live:
* BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2 when those two routes didn't
* exist yet) -- a real navigation gap this Phase 9 pass caught, not a cosmetic one. 文件助手 used to
* be a third entry here (reusing the same `slf`-conversation redirect the desktop NavRail icon did)
* but was removed along with every other entry point -- see ChatListColumn.tsx's PINNED_ENTRIES
* doc comment for why.
*/
const MOBILE_SHORTCUTS = [
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' },
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
];
/** README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). */
export function SettingsLayout() {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
const [loggingOut, setLoggingOut] = useState(false);
// README §8.7 draws TWO separate mobile screens (`#1d` 我的 hub, `#1e` 设置 sub-list) that don't
// map 1:1 onto the desktop directory -- this is which of the two the bare `/settings` mobile route
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
const [mobileSubView, setMobileSubView] = useState(false);
const logout = async () => {
if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return;
setLoggingOut(true);
// docs/IM-后端问题清单.md 问题22:这个接口此前三个候选路径都404,本地清理是唯一手段,最近
// 复核确认它其实已经真实可用(同一token调用前后分别200/401,确认真的会被吊销)。必须在清空
// sessionStorage的token之前调用(`authToken()`读的就是这个值),且失败不能阻塞本地清理这条
// 兜底路径——不管服务端这次撤销成不成功,用户在这台设备上都应该被清出登录态。
await revokeServerSession().catch(() => {});
resetTinode();
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
// Deliberately NOT clearTenant() -- that clears useSessionStore's `tenant`, and LoginPage's own
// `if (!tenant) return <Navigate to="/tenant" replace />` immediately bounces the navigate('/login')
// below straight back to the tenant-code entry screen instead of this tenant's actual login page
// (confirmed live: this used to be a real dead end, not a hypothetical). A plain logout should
// stay on the same enterprise's login screen, ready for the same or a different user to sign back
// in -- only the explicit "切换企业" link is supposed to clear the tenant.
//
// The QueryClient instance is module-scoped for the whole SPA lifetime (App.tsx) and survives a
// client-side route change -- without this, a second account logging in on the same tab/device
// (a shared kiosk machine, realistic for an enterprise client) would briefly render the FIRST
// account's still-cached profile/privacy/blocks data before the background refetch resolves.
queryClient.clear();
navigate('/login');
};
// Below the desktop breakpoint there's no room to dock both columns (confirmed live: without
// this, the 260px directory and the content column both try to render side by side on a phone-
// width viewport, squeezing the content into a sliver narrow enough that its own text wraps
// one character per line). Mirrors ContactsLayout's exact `showListOnNarrow` pattern: show the
// directory only at the bare `/settings` index, content only once a specific section is picked.
const showListOnNarrow = location.pathname === '/settings';
return (
<div className="flex h-full w-full">
<aside className={`h-full w-full flex-none flex-col bg-white md:flex md:w-[260px] md:border-r md:border-border-panel ${showListOnNarrow ? 'flex' : 'hidden'}`}>
{/* Desktop (README §8.1): one directory, matches ≥md exactly as before. */}
<div className="hidden h-full flex-1 flex-col md:flex">
<header className="flex h-15 flex-none items-center px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
</header>
<div className="flex items-center gap-3 border-b border-border-list-item px-5 pb-4.5 pt-2">
<Avatar
id={meQuery.data?.user_id || ''}
name={meQuery.data?.profile.nickname}
avatarFileId={meQuery.data?.profile.avatar_file_id}
className="h-12 w-12 flex-none rounded-xl text-base"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<strong className="truncate text-[15px] font-semibold text-text-body">{meQuery.data?.profile.nickname}</strong>
<span className="truncate text-xs text-text-tertiary-2">ID:{meQuery.data?.profile.public_account}</span>
</span>
</div>
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
{DIRECTORY.map((item) => {
const active = location.pathname.startsWith(item.to);
return (
<Link
key={item.key}
to={item.to}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm no-underline ${
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
}`}
>
<Icon name={item.icon} className="text-[18px]" />
<span className="flex-1">{item.label}</span>
</Link>
);
})}
</div>
<button
type="button"
onClick={logout}
disabled={loggingOut}
className="mx-3 my-4 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
>
退出登录
</button>
</div>
{/* Mobile hub (README §8.7, mock `07/1d`) -- a "me" landing page, not a settings directory:
tappable profile card (-> /settings/profile), 我的收藏/通话记录 (this app's only way to
reach those two on a narrow viewport, see MOBILE_SHORTCUTS' own comment), then
账号与安全/隐私 direct, and "设置" as its own second-level list rather than a 4th direct
link -- these two screens genuinely don't share one flat structure in the mock. */}
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'hidden' : 'flex'}`}>
<Link to="/settings/profile" className="flex items-center gap-3.5 bg-white px-5 py-5 no-underline">
<Avatar
id={meQuery.data?.user_id || ''}
name={meQuery.data?.profile.nickname}
avatarFileId={meQuery.data?.profile.avatar_file_id}
className="h-16 w-16 flex-none rounded-2xl text-2xl"
/>
<strong className="flex-1 truncate text-[19px] font-semibold text-text-heading">{meQuery.data?.profile.nickname}</strong>
<Icon name="qr_code_2" className="flex-none text-xl text-text-tertiary-3" />
<Icon name="chevron_right" className="flex-none text-xl text-icon-arrow" />
</Link>
<div className="flex flex-col gap-2.5 py-2.5">
<div className="flex flex-col bg-white">
{MOBILE_SHORTCUTS.map((item, i) => (
<Link
key={item.key}
to={item.to}
className={`flex items-center gap-3 px-5 py-3.5 text-base text-text-body no-underline ${i < MOBILE_SHORTCUTS.length - 1 ? 'border-b border-border-list-item' : ''}`}
>
<Icon name={item.icon} className={`text-xl ${item.colorClass}`} />
<span className="flex-1">{item.label}</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
))}
</div>
<div className="flex flex-col bg-white">
<Link to="/settings/security" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<Icon name="security" className="text-xl text-[#7c5cf6]" />
<span className="flex-1">账号与安全</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<Link to="/settings/privacy" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<Icon name="lock" className="text-xl text-[#e2537a]" />
<span className="flex-1">隐私</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<button type="button" onClick={() => setMobileSubView(true)} className="flex items-center gap-3 px-5 py-3.5 text-left text-base text-text-body">
<Icon name="settings" className="text-xl text-text-tertiary-3" />
<span className="flex-1">设置</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</button>
</div>
</div>
</div>
{/* Mobile "设置" sub-list (mock `07/1e`) -- a second, DIFFERENT grouping from both the hub
above and the desktop directory: 清空缓存/意见反馈 sit here directly, not nested under
通用. 清空缓存 links through to 通用 rather than duplicating that action (and its
already-established "no fake byte count" honesty) in a second place; 意见反馈 has no
generic post-login feedback endpoint anywhere in this protocol (the only feedback API,
`/tenant-entry-feedback`, is unauthenticated and pre-login-only per its own name) so this
is a real, recorded backend gap, not a missed wire-up. 关于 MeetingIM removed entirely per
product decision -- its only real content was a version string and an update-check the
backend rejects for `platform=web`, not worth a dedicated page. */}
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'flex' : 'hidden'}`}>
<div className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center bg-white px-1">
<button type="button" onClick={() => setMobileSubView(false)} className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</button>
<strong className="text-center text-[17px] font-semibold text-text-heading">设置</strong>
<span />
</div>
<div className="flex flex-col gap-2.5 py-2.5">
<div className="flex flex-col bg-white">
{DIRECTORY.filter((item) => item.key !== 'profile').map((item, i, arr) => (
<Link
key={item.key}
to={item.to}
className={`flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline ${i < arr.length - 1 ? 'border-b border-border-list-item' : ''}`}
>
<span>{item.label}</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
))}
</div>
<div className="flex flex-col bg-white">
<Link to="/settings/general" className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<span>清空缓存</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<button type="button" onClick={notImplemented} className="flex items-center justify-between px-5 py-3.5 text-left text-base text-text-body">
<span>意见反馈</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</button>
</div>
<button type="button" onClick={logout} disabled={loggingOut} className="bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50">
退出登录
</button>
</div>
</div>
</aside>
<div className={`h-full min-w-0 flex-1 flex-col overflow-y-auto bg-[#f6f7f9] md:flex ${showListOnNarrow ? 'hidden' : 'flex'}`}>
<Link to="/settings" className="flex h-11 flex-none items-center gap-1 px-4 text-sm text-text-secondary no-underline md:hidden">
<Icon name="arrow_back" className="text-[18px]" />
设置
</Link>
<Outlet />
</div>
</div>
);
}