fix(web): 隐藏文件助手入口,修复自聊天详情栏错误操作

后端目前没有支持文件助手所需的配额/分类等能力,只是把它当作
普通话题处理;与其做一个功能不全的界面,先从导航栏、会话列表
置顶区、通讯录固定入口、设置页移动端快捷入口中移除该入口。slf
话题现在按普通会话行为处理(排序、未读计数均不再特殊处理)。

同时修复 DetailColumn 对自聊天(slf)显示语音/视频通话、备注、
拉黑、举报、删除好友等对自己无意义操作的问题,改为仅显示适用
的发消息/查找聊天记录/置顶/免打扰/清空聊天记录等项。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 23:35:58 +08:00
co-authored by Claude Sonnet 5
parent 3065e232be
commit 9271a0a9c1
6 changed files with 97 additions and 98 deletions
+8 -6
View File
@@ -24,13 +24,15 @@ export function AppShell() {
useEffect(() => { useEffect(() => {
initCallSignaling(); initCallSignaling();
}, []); }, []);
// "sys"/"slf" have their own authoritative unread source in entry_badges (bootstrap.ts) -- excluded // "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here
// here in case they ever also show up as regular Tinode subscriptions in `contacts`, which would // in case it ever also shows up as a regular Tinode subscription in `contacts`, which would
// otherwise double-count the same unread messages into this badge. // otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
// 文件助手 (which used to own its own entry_badges count, file_assistant_unread_count) has been
// removed as a UI entry point entirely -- see ChatListColumn.tsx's PINNED_ENTRIES doc comment --
// so "slf" is now just an ordinary contact row like any other, and should count normally here too.
const unreadMessages = const unreadMessages =
contacts.reduce((sum, c) => (c.topic === 'sys' || c.topic === 'slf' ? sum : sum + (c.unread || 0)), 0) + contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) +
(bootstrap.data?.entry_badges?.system_unread_count || 0) + (bootstrap.data?.entry_badges?.system_unread_count || 0);
(bootstrap.data?.entry_badges?.file_assistant_unread_count || 0);
return ( return (
<div className="flex h-dvh w-full overflow-hidden bg-surface-page"> <div className="flex h-dvh w-full overflow-hidden bg-surface-page">
+4 -14
View File
@@ -13,7 +13,6 @@ const TOP_ITEMS: NavRailItem[] = [
{ to: '/messages', icon: 'chat_bubble', label: '消息' }, { to: '/messages', icon: 'chat_bubble', label: '消息' },
{ to: '/contacts', icon: 'contacts', label: '通讯录' }, { to: '/contacts', icon: 'contacts', label: '通讯录' },
{ to: '/favorites', icon: 'inventory_2', label: '收藏' }, { to: '/favorites', icon: 'inventory_2', label: '收藏' },
{ to: '/files', icon: 'folder', label: '文件助手' },
]; ];
const BOTTOM_ITEMS: NavRailItem[] = [ const BOTTOM_ITEMS: NavRailItem[] = [
@@ -21,14 +20,8 @@ const BOTTOM_ITEMS: NavRailItem[] = [
{ to: '/settings', icon: 'settings', label: '设置' }, { to: '/settings', icon: 'settings', label: '设置' },
]; ];
/** `/files` is `<Navigate to="/messages/slf" replace />` (App.tsx) -- the real URL right after
* clicking "文件助手" is `/messages/slf`, which a plain (non-`end`) NavLink match makes "消息"
* light up for (a same-topicId prefix match) while "文件助手" itself (`to: '/files'`, a path that
* no longer exists once redirected) never matches at all. Overriding both items' active state off
* the real pathname instead of trusting NavLink's own prefix match fixes both sides of the mix-up. */
function isNavItemActive(to: string, pathname: string): boolean { function isNavItemActive(to: string, pathname: string): boolean {
if (to === '/files') return pathname === '/messages/slf'; if (to === '/messages') return pathname.startsWith('/messages');
if (to === '/messages') return pathname.startsWith('/messages') && pathname !== '/messages/slf';
return pathname === to || pathname.startsWith(`${to}/`); return pathname === to || pathname.startsWith(`${to}/`);
} }
@@ -39,12 +32,9 @@ function NavRailButton({ item }: { item: NavRailItem }) {
// Plain Link, not NavLink -- NavLink computes its OWN `aria-current` from ITS OWN prefix-match // Plain Link, not NavLink -- NavLink computes its OWN `aria-current` from ITS OWN prefix-match
// logic unconditionally (confirmed by reading react-router's source: passing an `aria-current` // logic unconditionally (confirmed by reading react-router's source: passing an `aria-current`
// prop only changes the STRING used, not whether it's applied at all, which stays gated on // prop only changes the STRING used, not whether it's applied at all, which stays gated on
// NavLink's internal isActive). That's exactly `/files`'s own problem above -- NavLink's naive // NavLink's internal isActive), so a second, independent aria-current computation could in
// match would light up "消息" for `/messages/slf`, not "文件助手" -- so a second, independent // principle disagree with the `className` highlight computed by `isActive` below. Driving both
// aria-current computation fighting the same override this file already needed for `className` // off the one shared `isActive` value keeps them impossible to desync.
// is a real, verifiable a11y bug (screen readers and sighted highlighting would disagree on
// which icon is current). Driving both off the one already-correct `isActive` avoids that
// entirely.
<Link <Link
to={item.to} to={item.to}
title={item.label} title={item.label}
@@ -12,7 +12,6 @@ import { getBlocks, getFriendRequests, getFriends } from '@/lib/api/friends';
const FIXED_ENTRIES = [ const FIXED_ENTRIES = [
{ key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' }, { key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' },
{ key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' }, { key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' },
{ key: 'files', route: '/files', icon: 'folder', label: '文件助手', colorClass: 'bg-success-bg text-success' },
{ key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' }, { key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' },
// README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1), // README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1),
// §5.1's desktop entry list doesn't include it. // §5.1's desktop entry list doesn't include it.
@@ -27,7 +26,8 @@ function groupInitial(name: string, initial: string | undefined): string {
} }
/** /**
* README §5.1 (320px): search + 4 fixed entries (新朋友/群聊/文件助手/黑名单) + letter-grouped * README §5.1 (320px): search + fixed entries (新朋友/群聊/黑名单, 文件助手 removed -- see
* ChatListColumn.tsx's PINNED_ENTRIES doc comment for why) + letter-grouped
* friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both * friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both
* grouped and searched client-side, same convention ChatListColumn already uses for conversations * grouped and searched client-side, same convention ChatListColumn already uses for conversations
* -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke. * -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke.
+14 -27
View File
@@ -15,17 +15,22 @@ import { useChatList, useTinode } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
/** /**
* README §2.1's two always-pinned utility rows. Real, confirmed backend facts (docs/IM-前后端对接 * README §2.1's always-pinned utility row. Real, confirmed backend fact (docs/IM-前后端对接
* 文档.md:909): topic names are fixed ("slf"/"sys"), but 系统消息's unread comes from a separate * 文档.md:909): topic name is fixed ("sys"), but 系统消息's unread comes from a separate
* notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline * notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline
* (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like * (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like
* every `:topicId` conversation, not a standalone top-level route, so the chat list column stays * every `:topicId` conversation, not a standalone top-level route, so the chat list column stays
* docked next to it (a user report caught the list vanishing when this was still `/chat/system`, * docked next to it (a user report caught the list vanishing when this was still `/chat/system`,
* a route outside MessagesLayout entirely). `/files` redirects into a real * a route outside MessagesLayout entirely).
* ConversationView on the `slf` topic instead -- README §9.2's own dedicated 3-column file browser *
* (categories, storage quota, upload progress) has no backing REST API at all (confirmed via a * 文件助手 (a pinned entry that redirected into a plain ConversationView on the `slf` topic --
* research pass), so a plain conversation view is the honest choice over a quota card with invented * README §9.2's own dedicated 3-column file browser has no backing REST API at all, confirmed via a
* numbers. * research pass) was deliberately removed from here: the user found it confusing -- it's really
* just a chat thread with yourself wearing a folder icon, and DetailColumn's friend-only actions
* (拉黑/举报/删除好友) made no sense pointed at yourself. Hidden across every entry point (NavRail,
* here, ContactsListColumn, SettingsLayout's mobile shortcuts) rather than half-built into a real
* file browser the backend can't support yet. `slf` itself is untouched -- if it ever has real
* message history it still surfaces as an perfectly ordinary contact row, sorted like any other.
*/ */
const PINNED_ENTRIES = [ const PINNED_ENTRIES = [
{ {
@@ -36,14 +41,6 @@ const PINNED_ENTRIES = [
route: '/messages/system', route: '/messages/system',
colorClass: 'bg-warn-bg text-warn', colorClass: 'bg-warn-bg text-warn',
}, },
{
key: 'file_assistant' as const,
badgeKey: 'file_assistant_unread_count' as const,
label: '文件助手',
icon: 'folder',
route: '/files',
colorClass: 'bg-success-bg text-success',
},
]; ];
/** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity) /** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity)
@@ -68,14 +65,7 @@ export function ChatListColumn() {
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count])); const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [creatingGroup, setCreatingGroup] = useState(false); const [creatingGroup, setCreatingGroup] = useState(false);
// `slf` (self-chat) is permanently represented by the pinned 文件助手 entry above (see const sorted = [...contacts].sort((a, b) => {
// PINNED_ENTRIES's own doc comment) -- it also comes back as an ordinary contact once it has any
// real message history (e.g. the very first time something gets sent through 文件助手), which
// without this filter renders it a SECOND time as its own chronologically-sorted row further down.
// Confirmed live: with both rows present, navigating to /messages/slf (via either one) satisfies
// both rows' own "am I the active one" check at once, highlighting them simultaneously -- a real,
// reproducible duplicate-representation bug, not just a cosmetic double-highlight.
const sorted = [...contacts].filter((c) => c.topic !== 'slf').sort((a, b) => {
const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0); const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0);
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0); return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
}); });
@@ -105,10 +95,7 @@ export function ChatListColumn() {
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{pinned.map((entry) => { {pinned.map((entry) => {
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0; const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
// `/files` redirects to `/messages/slf`, so its "currently open" check is keyed off the const active = location.pathname === entry.route;
// resulting topicId rather than the route it navigates from, unlike `/messages/system`
// which is the real, final path.
const active = entry.route === '/files' ? topicId === 'slf' : location.pathname === entry.route;
return ( return (
<Link <Link
key={entry.key} key={entry.key}
+21 -2
View File
@@ -70,10 +70,19 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
setErrorToast(''); setErrorToast('');
}, [topicId]); }, [topicId]);
// `slf` (self-chat, e.g. opened via a chat-list row once it has real history -- there is no
// "文件助手" entry point pointing at it anymore, see ChatListColumn.tsx's PINNED_ENTRIES doc
// comment) isn't a friend and isn't really "someone else" at all -- getUserProfile('slf') has no
// real user to look up, and every friend-only action below (remark/block/report/delete friend)
// is nonsensical pointed at yourself. Gated out entirely rather than left to silently no-op or
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
// to yourself.
const isSelf = topicId === 'slf';
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) }); const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const friend = friendsQuery.data?.find((f) => f.user_id === topicId); const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId) }); const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId), enabled: !isSelf });
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region] const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');
@@ -160,6 +169,8 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
<Icon name="chat_bubble_outline" className="text-[20px]" /> <Icon name="chat_bubble_outline" className="text-[20px]" />
<span className="text-xs text-text-secondary">发消息</span> <span className="text-xs text-text-secondary">发消息</span>
</button> </button>
{isSelf ? null : (
<>
<button <button
type="button" type="button"
disabled={profileQuery.data?.capabilities?.can_call === false} disabled={profileQuery.data?.capabilities?.can_call === false}
@@ -182,6 +193,8 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
<Icon name="videocam" className="text-[20px]" /> <Icon name="videocam" className="text-[20px]" />
<span className="text-xs text-text-secondary">视频</span> <span className="text-xs text-text-secondary">视频</span>
</button> </button>
</>
)}
</div> </div>
</div> </div>
@@ -208,7 +221,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
</div> </div>
<div className="flex flex-col border-b border-border-list-item px-5 text-sm"> <div className="flex flex-col border-b border-border-list-item px-5 text-sm">
{remarkEditing ? ( {isSelf ? null : remarkEditing ? (
<div className="flex items-center gap-2 py-3"> <div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span> <span className="flex-none text-text-body">备注</span>
<input <input
@@ -254,10 +267,12 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })} onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
/> />
</div> </div>
{isSelf ? null : (
<div className="flex items-center justify-between py-3"> <div className="flex items-center justify-between py-3">
<span className="text-text-body">加入黑名单</span> <span className="text-text-body">加入黑名单</span>
<Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} /> <Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} />
</div> </div>
)}
</div> </div>
<div className="flex flex-col px-5 pb-2 text-sm"> <div className="flex flex-col px-5 pb-2 text-sm">
@@ -265,12 +280,15 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
<span className="text-text-body">清空聊天记录</span> <span className="text-text-body">清空聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" /> <Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button> </button>
{isSelf ? null : (
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left"> <button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">投诉举报</span> <span className="text-text-body">投诉举报</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" /> <Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button> </button>
)}
</div> </div>
{isSelf ? null : (
<button <button
type="button" type="button"
onClick={startDeleteFriend} onClick={startDeleteFriend}
@@ -278,6 +296,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
> >
删除好友 删除好友
</button> </button>
)}
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} /> <Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
</aside> </aside>
+11 -10
View File
@@ -19,17 +19,18 @@ const DIRECTORY = [
]; ];
/** /**
* README §8.7 (mobile "我的" page): 我的收藏/文件助手/通话记录 are grouped ABOVE the regular * README §8.7 (mobile "我的" page): 我的收藏/通话记录 are grouped ABOVE the regular settings
* settings directory. Desktop never needs this list -- those three already have their own * directory. Desktop never needs this list -- those already have their own permanent NavRail icons
* permanent NavRail icons there -- but on a phone-width viewport (bottom tab bar instead of a rail) * there -- but on a phone-width viewport (bottom tab bar instead of a rail) there is otherwise NO
* there is otherwise NO way to reach `/favorites` or `/calls` at all short of typing the URL * way to reach `/favorites` or `/calls` at all short of typing the URL directly (confirmed live:
* directly (confirmed live: BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2 * BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2 when those two routes didn't
* when those two routes didn't exist yet) -- a real navigation gap this Phase 9 pass caught, not a * exist yet) -- a real navigation gap this Phase 9 pass caught, not a cosmetic one. 文件助手 used to
* cosmetic one. `/files` reuses the same `slf`-conversation redirect the desktop entry point does. * 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 = [ const MOBILE_SHORTCUTS = [
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' }, { 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' }, { key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
]; ];
@@ -131,8 +132,8 @@ export function SettingsLayout() {
</div> </div>
{/* Mobile hub (README §8.7, mock `07/1d`) -- a "me" landing page, not a settings directory: {/* 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 tappable profile card (-> /settings/profile), 我的收藏/通话记录 (this app's only way to
way to reach those three on a narrow viewport, see MOBILE_SHORTCUTS' own comment), then 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 账号与安全/隐私 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. */} 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'}`}> <div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'hidden' : 'flex'}`}>