- 头像上传从未带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>
239 lines
14 KiB
TypeScript
239 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|