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

新增全局 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
@@ -34,6 +34,7 @@ import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
import { openUserCard } from '@/stores/userCard';
interface GroupDetailColumnProps {
topicId: string;
@@ -363,7 +364,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
</button>
<div className="grid grid-cols-5 gap-2 md:mt-2">
{members.slice(0, 9).map((m) => (
<div key={m.user_id} className="relative flex flex-col items-center gap-1">
<button
key={m.user_id}
type="button"
onClick={() => openUserCard(m.user_id)}
className="relative flex flex-col items-center gap-1"
>
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
desktop's 40px (unconditional here before this fix). */}
<Avatar
@@ -374,7 +380,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
/>
<RoleCorner role={m.role} />
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
</div>
</button>
))}
{canInvite ? (
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
@@ -10,6 +10,7 @@ import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMem
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
import { openUserCard } from '@/stores/userCard';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct
@@ -190,10 +191,12 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia
) : (
filtered.map((m) => (
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
</button>
<RoleTag role={m.role} />
{canRemoveMember(myUserId, myRole, m) ? (
<button
+7 -6
View File
@@ -23,6 +23,7 @@ import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/mess
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics';
import { openUserCard } from '@/stores/userCard';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
@@ -173,10 +174,10 @@ export function MessageRow({
relationship isn't a case this app has ever needed to support. Not during `selectMode`
either -- the checkbox is the only thing a tap in this row should do while bulk-selecting,
not navigate away and lose whatever's already been picked. */}
{isGroup && !mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
<Avatar id={msg.from} name={groupSenderLabel(senderName)} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
</Link>
{!mine && !selectMode && msg.from ? (
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
</button>
) : (
<Avatar
id={msg.from || (isGroup ? '' : peerName)}
@@ -190,9 +191,9 @@ export function MessageRow({
{isGroup ? (
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
{groupSenderLabel(senderName)}
</Link>
</button>
) : (
<span>{mine ? '我' : groupSenderLabel(senderName)}</span>
)}