Files
im_webapp/web/src/features/settings/SettingsLayout.tsx
T
Your NameandClaude Sonnet 5 97e8d7144f fix(web): 第9轮审查批次4——设置域一批问题
- 头像上传从未带purpose=user_avatar:之前是照抄一个已经修复的旧后端
  bug(问题28)绕开的写法,现在确认接口早就支持这个purpose了,补上——
  uploadFile()新增可选purpose参数,不影响其它已有调用点(聊天附件/群
  文件继续用topic的chat_file路径)
- 桌面卡片行分隔线6处错用了移动端专属的border-list-item token,改成
  桌面正确的border-row
- 退出登录按钮外边距/设置目录导航图标尺寸(18px→20px)与设计稿对齐
- 地区字段补上maxLength兜底
- 修改密码没有用<form>包裹,输入框按回车没反应——补上表单+onSubmit
- 记录一条此前没进清单的后端缺口:"加我为好友时需要验证"这个隐私开关
  设计稿画了,但响应体里根本没有对应字段

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:46:48 +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-row 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-[20px]" />
<span className="flex-1">{item.label}</span>
</Link>
);
})}
</div>
<button
type="button"
onClick={logout}
disabled={loggingOut}
className="mx-5 mt-auto mb-5 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>
);
}