新增点击头像/昵称查看用户资料弹窗,接入消息气泡/@提及/群成员列表

新增全局 UserCardRoot(stores/userCard.ts 驱动,挂载在 AppShell 里,模式同
ConfirmDialogRoot),复用 getUserProfile 数据源,展示头像/昵称/性别地区/签名,
非本人时带发消息/语音/视频快捷操作,底部"查看完整资料"跳转到完整的
/contacts/{id} 页面。

接入点:
- MessageBubble.tsx:群聊和单聊的对方头像/昵称(原来只有群聊头像/昵称是
  Link 到完整资料页,单聊完全没有交互)
- drafty.tsx:@提及实体(Drafty.mention 的 uid 存在 ent[].data.val 里,
  之前渲染完全没读这个字段),"所有人"哨兵值除外
- GroupMembersModal.tsx / GroupDetailColumn.tsx:群成员列表的行内和紧凑
  头像格子

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-24 17:45:23 +08:00
co-authored by Claude Sonnet 5
parent 2427a3782c
commit ef31eb2e88
7 changed files with 200 additions and 13 deletions
+2
View File
@@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Outlet, useNavigate } from 'react-router-dom';
import { UserCardRoot } from '@/app/UserCardRoot';
import { CallRoot } from '@/features/calls/CallRoot';
import { useBootstrap } from '@/lib/api/hooks';
import { getMe } from '@/lib/api/profile';
@@ -98,6 +99,7 @@ export function AppShell() {
</div>
</div>
<CallRoot />
<UserCardRoot />
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { ImApiError } from '@/lib/api/client';
import { getUserProfile } from '@/lib/api/friends';
import { useEscapeKey } from '@/lib/hooks';
import { useCallStore } from '@/stores/calls';
import { useSessionStore } from '@/stores/session';
import { closeUserCard, useUserCardStore } from '@/stores/userCard';
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
/** Mounted once at the app root (App.tsx) -- renders whatever `openUserCard()` (stores/userCard.ts)
* most recently asked for. A lighter, faster-to-reach sibling of FriendProfileView's full `/contacts/
* :userId` page (same `getUserProfile` data source, same relationship-aware action gating), not a
* replacement for it -- "查看完整资料" below still routes there for remark-editing/QR code/etc. */
export function UserCardRoot() {
const userId = useUserCardStore((s) => s.userId);
// Always registered (hooks can't be conditional), matching ConfirmDialogRoot's own reasoning --
// a no-op onEscape while no card is open is harmless.
useEscapeKey(closeUserCard);
if (!userId) return null;
// key={userId} so switching straight from one card to another (tapping a different @mention
// before closing the first) resets the query/local state instead of showing stale data mid-fetch.
return <UserCardPanel key={userId} userId={userId} />;
}
function UserCardPanel({ userId }: { userId: string }) {
const navigate = useNavigate();
const myUserId = useSessionStore((s) => s.myUserId);
const isSelf = !!myUserId && myUserId === userId;
const query = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => getUserProfile(userId),
});
const profile = query.data;
const name = profile ? profile.remark || profile.display_name : '';
const goToFullProfile = () => {
closeUserCard();
navigate(isSelf ? '/settings/profile' : `/contacts/${userId}`);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={closeUserCard}>
<div className="w-[300px] overflow-hidden rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end px-3 pt-3">
<button
type="button"
onClick={closeUserCard}
className="grid h-7 w-7 place-items-center rounded-full text-text-tertiary-2 hover:bg-surface-input"
>
<Icon name="close" className="text-[18px]" />
</button>
</div>
{query.isLoading ? (
<div className="flex h-44 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : !profile ? (
<div className="flex h-44 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-text-tertiary-4">
{query.error instanceof ImApiError && query.error.status === 403 ? (
'对方设置了资料保护,暂时无法查看'
) : query.error instanceof ImApiError && query.error.status === 404 ? (
'未找到该用户'
) : (
<>
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</>
)}
</div>
) : (
<div className="flex flex-col items-center px-6 pb-6">
<Avatar id={profile.user_id} name={name} avatarFileId={profile.avatar_file_id} className="h-18 w-18 flex-none rounded-2xl text-xl" />
<strong className="mt-3 max-w-full truncate text-base font-semibold text-text-heading">{name}</strong>
{profile.remark ? <span className="mt-0.5 max-w-full truncate text-[13px] text-text-tertiary-2">昵称:{profile.display_name}</span> : null}
{profile.gender || profile.region ? (
<span className="mt-0.5 text-[13px] text-text-tertiary-2">
{[profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region].filter(Boolean).join(' · ')}
</span>
) : null}
{profile.signature ? <p className="m-0 mt-2 line-clamp-3 text-center text-sm text-text-secondary">{profile.signature}</p> : null}
{!isSelf ? (
<div className="mt-5 flex w-full items-center gap-2">
<button
type="button"
disabled={profile.capabilities?.can_send === false}
onClick={() => {
closeUserCard();
navigate(`/messages/${profile.user_id}`);
}}
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:opacity-50"
>
<Icon name="chat_bubble" className="text-base" />
发消息
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase !== 'idle') return;
closeUserCard();
useCallStore.getState().startCall(profile.user_id, name, 'audio');
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
>
<Icon name="call" className="text-base" />
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase !== 'idle') return;
closeUserCard();
useCallStore.getState().startCall(profile.user_id, name, 'video');
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
>
<Icon name="videocam" className="text-base" />
</button>
</div>
) : null}
<button type="button" onClick={goToFullProfile} className="mt-3 text-[13px] text-accent">
查看完整资料
</button>
</div>
)}
</div>
</div>
);
}