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
@@ -0,0 +1,243 @@
|
||||
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 { getMe } from '@/lib/api/profile';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
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: '通用' },
|
||||
{ key: 'about', to: '/settings/about', icon: 'info', label: '关于 MeetingIM' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.7 (mobile "我的" page): 我的收藏/文件助手/通话记录 are grouped ABOVE the regular
|
||||
* settings directory. Desktop never needs this list -- those three 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. `/files` reuses the same `slf`-conversation redirect the desktop entry point does.
|
||||
*/
|
||||
const MOBILE_SHORTCUTS = [
|
||||
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' },
|
||||
{ key: 'files', to: '/files', icon: 'folder', label: '文件助手', colorClass: 'text-success' },
|
||||
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). Logout is entirely
|
||||
* client-side -- confirmed live (docs/IM-后端问题清单.md 问题22) all three plausible logout
|
||||
* endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which
|
||||
* never calls a server logout endpoint at all, just clears local token/session and disconnects.
|
||||
*/
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (!window.confirm('确定退出登录吗?')) return;
|
||||
setLoggingOut(true);
|
||||
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 three 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. 关于 omits the mock's red-dot
|
||||
"有新版本" -- already confirmed elsewhere that state can never be real (the update-check
|
||||
endpoint always rejects `platform=web`), showing it would be lying about system state. */}
|
||||
<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' && item.key !== 'about').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 border-b border-border-list-item 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>
|
||||
<Link to="/settings/about" className="flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<span>关于 MeetingIM</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user