gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/utils/user.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,367 @@
// ====================================================================
// IM ç”¨æˆ·å±•示 utility
// ====================================================================
// èŒè´£ï¼šç»Ÿä¸€å›žç­”"某个用户在 UI ä¸Šåº”该如何展示",包含:
// 1. æ˜¾ç¤ºåï¼ˆgetFriendDisplayName / getMemberDisplayName / getGroupDisplayName / getSenderDisplayName ç­‰ï¼‰
// 2. ä¸Šä¸‹æ–‡æ„ŸçŸ¥åï¼ˆtryGetSenderDisplayName / getSenderRealNickname):渲染时按 conversation ä¸Šä¸‹æ–‡å®žæ—¶æŸ¥ friendStore / groupStore / userStore,让备注 / ç¾¤æ˜µç§° / çœŸå®žæ˜µç§°å˜æ›´åŽæ‰€æœ‰åŽ†å²æ¶ˆæ¯ç«‹å³å“åº”å¼åˆ·æ–°
// 3. æ€§åˆ«å›¾æ ‡ / é¢œè‰²ï¼ˆgetGenderIcon / getGenderColor):男蓝、女粉,未知不展示,所有 UserInfo å¡ç‰‡ / åˆ—表行共用
//
// å‘½åçº¦å®šï¼šæ˜¾ç¤ºåç›¸å…³å‡½æ•°ä¸€å¾‹ä½¿ç”¨ displayName,与 friend.displayName / member.displayUserName å­—段对齐
// ====================================================================
import type { Conversation, Friend, Group, User } from '../home/types'
import { CommonStatusEnum } from '#/packages/constants/src'
import { useUserStore } from '#/packages/stores/src'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useConversationStore } from '../home/store/conversationStore'
import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore'
import { useImUiStore } from '../home/store/uiStore'
import {
  IM_AT_ALL_NICKNAME,
  IM_AT_ALL_USER_ID,
  ImConversationType,
  ImFriendAddSource
} from './constants'
import { type MentionCandidate } from './message'
const SystemUserSexEnum = {
  FEMALE: 2,
  MALE: 1,
  UNKNOWN: 0
} as const
// å€™é€‰ç¼ºå¤±åœºæ™¯çš„稳定空数组;让 textMentions computed åœ¨éž TEXT / éžç¾¤èŠ / æ—  @ æ—¶è¿”回同一引用,
// MessageBubble çš„ textSegments æ‰ä¸ä¼šè·Ÿç€æ— è°“重算
const EMPTY_MENTIONS: MentionCandidate[] = []
/**
 * æ˜¯å¦åŽ†å²é€€ç¾¤ç¾¤ï¼šjoinStatus ä¸º DISABLE(已退群 / è¢«ç§»é™¤ï¼‰
 *
 * /im/group/list ä¼šå¸¦å›žåŽ†å²é€€ç¾¤ç¾¤ï¼ˆä¾›å±•ç¤ºåŽ†å²æ¶ˆæ¯çš„ç¾¤å / å¤´åƒï¼‰ï¼›è¿™ç±»ç¾¤åº”禁止发送、隐藏群操作入口、不可被转发 / æŽ¨èé€‰ä¸­
 */
export function isGroupQuit(group?: Group | null): boolean {
  return group?.joinStatus === CommonStatusEnum.DISABLE
}
/**
 * ç§èŠå¥½å‹æ˜¾ç¤ºåï¼šå¤‡æ³¨ > çœŸå®žæ˜µç§°
 *
 * displayName æ˜¯ã€Œæˆ‘对这个人的私人称呼」属于我的数据,删好友(DISABLE)也保留;删了再加回来时备注自然延续,历史消息里仍以备注辨识
 */
export function getFriendDisplayName(friend: Pick<Friend, 'displayName' | 'nickname'>): string {
  return friend.displayName || friend.nickname
}
/**
 * ç¾¤æˆå‘˜æ˜¾ç¤ºåï¼šå¥½å‹å¤‡æ³¨ > ç”¨æˆ·ç¾¤å¤‡æ³¨ï¼ˆdisplayUserName) > çœŸå®žæ˜µç§°
 *
 * WeChat ä¼˜å…ˆçº§ï¼šå¥½å‹å¤‡æ³¨æ˜¯"我"对该成员的私人称呼,最高优先;其次是 ta åœ¨ç¾¤å†…自定义昵称;最后真实昵称兜底;调用方拿到 friend æ‰ä¼ å…¥ï¼Œæ²¡æ‹¿åˆ°ï¼ˆé™Œç”Ÿäººï¼‰å°±åªç”¨ member å­—段降级
 */
export function getMemberDisplayName(
  member: { displayUserName?: string; nickname: string },
  friend?: null | Pick<Friend, 'displayName'>
): string {
  return friend?.displayName || member.displayUserName || member.nickname
}
/** ç¾¤æ˜¾ç¤ºåï¼šå½“前用户对该群的备注(groupRemark) > ç¾¤åï¼ˆname) */
export function getGroupDisplayName(group: Pick<Group, 'groupRemark' | 'name'>): string {
  return group.groupRemark || group.name
}
/**
 * æ¶ˆæ¯å‘送者显示名(严格版):算不出真名返回 undefined
 *
 * ç»™éœ€è¦"是否真名"信号的调用方用——比如 conversationStore å†³å®šè¦ä¸è¦å†™ lastSenderDisplayName å¿«ç…§ã€è¦ä¸è¦è§¦å‘ fetchGroupMemberList å…œåº•拉成员
 *
 * GROUP åœºæ™¯ä¸‹ member å·²å°±ä½ä¼˜å…ˆ displayUserName / å¥½å‹å¤‡æ³¨ / çœŸå®žæ˜µç§°ï¼›member ç¼ºå¤±æ—¶åŒºåˆ†ä¸¤ç§ sender:
 * - self â†’ ç›´æŽ¥æ‹¿ userStore.nickname å…œåº•(本端永远知道自己的昵称,不需要 fetch;同时避免 self é€€ç¾¤åŽ GROUP_MEMBER_QUIT é€šçŸ¥è§¦å‘兜底拉成员 â†’ 403)
 * - å…¶ä»–人 â†’ è¿”回 undefined,让 deriveLastSenderDisplayName èµ°å…œåº•拉成员
 */
export function tryGetSenderDisplayName(
  senderId: number,
  conversationType: number,
  conversationTargetId: number
): string | undefined {
  if (conversationType === ImConversationType.GROUP) {
    const group = useGroupStore().getGroup(conversationTargetId)
    const member = group?.members?.find((m) => m.userId === senderId)
    if (member) {
      const friend = useFriendStore().getFriend(senderId)
      return getMemberDisplayName(member, friend)
    }
    if (senderId === getCurrentUserId()) {
      return useUserStore().userInfo?.nickname || undefined
    }
    return undefined
  }
  // PRIVATE / æœªçŸ¥ä¼šè¯ç±»åž‹ï¼šself èµ° userStore,对方走 friend
  if (senderId === getCurrentUserId()) {
    return useUserStore().userInfo?.nickname || undefined
  }
  if (conversationType === ImConversationType.PRIVATE) {
    const friend = useFriendStore().getFriend(senderId)
    return friend ? getFriendDisplayName(friend) : undefined
  }
  return undefined
}
/**
 * æ¶ˆæ¯å‘送者显示名:渲染时实时算,按 WeChat ä¼˜å…ˆçº§
 *
 * - è‡ªå·±ï¼šuserStore.nickname
 * - ç§èŠå¯¹æ–¹ï¼šå¥½å‹å¤‡æ³¨ > çœŸå®žæ˜µç§°
 * - ç¾¤èŠå¯¹æ–¹ï¼šå¥½å‹å¤‡æ³¨ > ç¾¤å¤‡æ³¨ï¼ˆdisplayUserName) > çœŸå®žæ˜µç§°
 * - æŸ¥ä¸åˆ°ï¼šfallbackName || (self èµ° userStore.nickname) || String(senderId)
 */
export function getSenderDisplayName(
  senderId: number,
  conversationType: number,
  conversationTargetId: number,
  fallbackName?: string
): string {
  const real = tryGetSenderDisplayName(senderId, conversationType, conversationTargetId)
  if (real) {
    return real
  }
  if (fallbackName) {
    return fallbackName
  }
  // self åœ¨ GROUP members æ²¡åŠ è½½æ—¶ï¼Œè‡³å°‘ç”¨çœŸå®žæ˜µç§°å…œåº•æ¸²æŸ“ï¼ˆæ¯” String(senderId) å‹å¥½ï¼‰ï¼›å…œåº•拉成员由 conversationStore è§¦å‘,回来后 try ç‰ˆæœ¬èƒ½å‘½ä¸­çœŸåè‡ªç„¶åˆ·æ–°
  const userStore = useUserStore()
  if (senderId === getCurrentUserId()) {
    return userStore.userInfo?.nickname || String(senderId)
  }
  return String(senderId)
}
/**
 * æ¶ˆæ¯å‘送者「真实昵称」:永远是 nickname,不掺备注
 *
 * - è‡ªå·±ï¼šuserStore.nickname
 * - ç§èŠå¯¹æ–¹ï¼šfriend.nickname
 * - ç¾¤èŠå¯¹æ–¹ï¼šmember.nickname
 *
 * ä¸“ç»™ UserAvatar çš„ :name ç”¨â€”—色卡首字母 / alt æ–‡æœ¬è¦ä¿è¯åŒä¸€ä¸ªäººåœ¨æ‰€æœ‰ç•Œé¢ä¸€è‡´ï¼Œä¸è·Ÿå¤‡æ³¨å˜
 */
export function getSenderRealNickname(
  senderId: number,
  conversationType: number,
  conversationTargetId: number
): string {
  const userStore = useUserStore()
  const selfUserId = getCurrentUserId()
  // ç¾¤èŠå…ˆèµ° member.nickname(self ä¹Ÿæ˜¯ member),异常时再走 self / senderId å…œåº•
  if (conversationType === ImConversationType.GROUP) {
    const group = useGroupStore().getGroup(conversationTargetId)
    const member = group?.members?.find((m) => m.userId === senderId)
    if (member?.nickname) {
      return member.nickname
    }
    if (senderId === selfUserId) {
      return userStore.userInfo?.nickname || String(senderId)
    }
    return String(senderId)
  }
  if (conversationType === ImConversationType.PRIVATE) {
    if (senderId === selfUserId) {
      return userStore.userInfo?.nickname || String(senderId)
    }
    const friend = useFriendStore().getFriend(senderId)
    return friend?.nickname || String(senderId)
  }
  if (senderId === selfUserId) {
    return userStore.userInfo?.nickname || String(senderId)
  }
  return String(senderId)
}
/**
 * æ¶ˆæ¯å‘送者头像;按 conversation ä¸Šä¸‹æ–‡å®žæ—¶æŸ¥ group.members / friend / userStore
 *
 * - è‡ªå·±ï¼šuserStore.avatar
 * - ç§èŠå¯¹æ–¹ï¼šfriend.avatar
 * - ç¾¤èŠå¯¹æ–¹ï¼šå…ˆ member.avatar,缺则降级 friend.avatar
 * - æŸ¥ä¸åˆ°ï¼šè¿”回空串,调用方走 UserAvatar è‰²å¡å…œåº•
 */
export function getSenderAvatar(
  senderId: number,
  conversationType: number,
  conversationTargetId: number
): string {
  const userStore = useUserStore()
  if (senderId === getCurrentUserId()) {
    return userStore.userInfo?.avatar || ''
  }
  if (conversationType === ImConversationType.GROUP) {
    const group = useGroupStore().getGroup(conversationTargetId)
    const member = group?.members?.find((m) => m.userId === senderId)
    if (member?.avatar) {
      return member.avatar
    }
  }
  return useFriendStore().getFriend(senderId)?.avatar || ''
}
/**
 * ç¾¤æ¶ˆæ¯ @ mention å€™é€‰ï¼šæŒ‰ atUserIds åæŸ¥ç¾¤æˆå‘˜ï¼Œname æ”¶é›†æ‰€æœ‰å¯èƒ½å­—面量(含好友备注 / ç¾¤è‡ªå®šä¹‰æ˜µç§°ï¼‰ï¼ŒdisplayName ç»Ÿä¸€æŒ‡å‘真实昵称
 *
 * åŒå­—面量被多 userId å…±äº«æ—¶æ ‡è®° ambiguous,由 parser æ•´æ®µåƒæˆæ™®é€šæ–‡æœ¬ï¼›
 * ç›´æŽ¥å‰”除会让短前缀候选(如「@张」)抢吃「@张三」的前缀,错绑到唯一的「张」用户
 */
export function getMentionCandidates(
  atUserIds: number[] | undefined,
  conversation: null | Pick<Conversation, 'targetId' | 'type'> | undefined
): MentionCandidate[] {
  if (!atUserIds || atUserIds.length === 0) {
    return EMPTY_MENTIONS
  }
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return EMPTY_MENTIONS
  }
  // ç¾¤æˆå‘˜é¢„建 Map,避免每个 atUserId èµ°ä¸€æ¬¡ array find(@全体成员场景下成员数 Ã— atUserIds æ˜¯ N²)
  const members = useGroupStore().getGroup(conversation.targetId)?.members || []
  const memberById = new Map(members.map((m) => [m.userId, m]))
  const friendStore = useFriendStore()
  const candidates: MentionCandidate[] = []
  const seen = new Set<string>()
  for (const userId of atUserIds) {
    // @全体成员是虚拟伪成员,userId = -1 åœ¨ group.members é‡ŒæŸ¥ä¸åˆ°ï¼Œæ³¨å…¥å­—面量「所有人」候选
    if (userId === IM_AT_ALL_USER_ID) {
      const key = `${IM_AT_ALL_USER_ID}#${IM_AT_ALL_NICKNAME}`
      if (!seen.has(key)) {
        seen.add(key)
        candidates.push({
          userId: IM_AT_ALL_USER_ID,
          name: IM_AT_ALL_NICKNAME,
          displayName: IM_AT_ALL_NICKNAME
        })
      }
      continue
    }
    const member = memberById.get(userId)
    const friend = friendStore.getFriend(userId)
    const nickname = (member?.nickname || friend?.nickname || '').trim()
    if (!nickname) {
      continue
    }
    for (const literal of [nickname, friend?.displayName, member?.displayUserName]) {
      const trimmed = (literal || '').trim()
      if (!trimmed) {
        continue
      }
      const key = `${userId}#${trimmed}`
      if (seen.has(key)) {
        continue
      }
      seen.add(key)
      candidates.push({ userId, name: trimmed, displayName: nickname })
    }
  }
  const nameCount = new Map<string, number>()
  for (const candidate of candidates) {
    nameCount.set(candidate.name, (nameCount.get(candidate.name) ?? 0) + 1)
  }
  for (const candidate of candidates) {
    if ((nameCount.get(candidate.name) ?? 0) > 1) {
      candidate.ambiguous = true
    }
  }
  return candidates
}
/**
 * æ–‡æœ¬ / tip é‡Œ mention æ®µç‚¹å‡»çš„统一入口:派生 User å¼¹ UserInfoCard
 *
 * å¤´åƒèµ° getSenderAvatar;昵称取真实昵称,friend / member éƒ½æŸ¥ä¸åˆ°æ—¶ç”¨ fallbackName å…œåº•(如灰条 tip æ®µçš„字面量文本);
 * addSource ç¾¤èŠå¸¦ GROUP + ç¾¤åï¼ˆç”¨äºŽã€ŒåŠ ä¸ºå¥½å‹ã€çš„æ¥æº + å¤‡æ³¨å¼¹æ–‡æ¡ˆï¼‰ï¼Œç§èŠé™çº§ SEARCH;
 * @全体成员 æ˜¯å¹¿æ’­ mention æ²¡æœ‰å…·ä½“用户实体,对齐微信 PC ä¸å¼¹å¡ç‰‡
 */
export function openMentionUserInfoCardAtEvent(
  userId: number,
  event: MouseEvent,
  fallbackName?: string
): void {
  if (userId === IM_AT_ALL_USER_ID) {
    return
  }
  const conversation = useConversationStore().activeConversation
  const isGroup = conversation?.type === ImConversationType.GROUP
  const group = isGroup && conversation ? useGroupStore().getGroup(conversation.targetId) : undefined
  const member = group?.members?.find((m) => m.userId === userId)
  const friend = useFriendStore().getFriend(userId)
  const user: User = {
    id: userId,
    nickname: friend?.nickname || member?.nickname || fallbackName || String(userId),
    avatar: getSenderAvatar(userId, conversation?.type ?? 0, conversation?.targetId ?? 0)
  }
  useImUiStore().openUserInfoCardAtEvent(
    user,
    event,
    isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
    isGroup ? group?.name || '' : ''
  )
}
/** æ€§åˆ«å›¾æ ‡ï¼›UNKNOWN / null / undefined ä¸€å¾‹ä¸å±•示,对齐微信留白 */
export function getGenderIcon(sex?: number): string {
  if (sex === SystemUserSexEnum.MALE) {
    return 'mdi:human-male'
  }
  if (sex === SystemUserSexEnum.FEMALE) {
    return 'mdi:human-female'
  }
  return ''
}
/** æ€§åˆ«å›¾æ ‡ä¸»é¢˜è‰²ï¼šç”·è“ã€å¥³ç²‰ */
export function getGenderColor(sex?: number): string {
  if (sex === SystemUserSexEnum.MALE) {
    return '#5b97f5'
  }
  if (sex === SystemUserSexEnum.FEMALE) {
    return '#f56c92'
  }
  return ''
}
/** å¤´åƒè‰²å¡åº•色调色板(参考微信) */
const AVATAR_BG_COLORS = ['#07C160', '#1A95FF', '#FA9D3B', '#9163E0', '#F76760', '#1ABC9C']
/** å¤´åƒè‰²å¡æ–‡å­—:中文取首字、英文取前 2 å­—母大写、其他取首字大写、空名返回空串 */
export function getAvatarText(name?: string): string {
  const trimmed = name?.trim()
  if (!trimmed) {
    return ''
  }
  const first = trimmed.charAt(0)
  const code = first.codePointAt(0) ?? 0
  if (code >= 0x4E_00 && code <= 0x9F_A5) {
    return first
  }
  const letters = trimmed.match(/[A-Za-z]/g)
  if (!letters || letters.length === 0) {
    return first.toUpperCase()
  }
  return letters.slice(0, 2).join('').toUpperCase()
}
/** å¤´åƒè‰²å¡åº•色:按 name charCode ä¹‹å’Œå–调色板色,空名走默认灰 */
export function getAvatarBgColor(name?: string): string {
  if (!name) {
    return '#909399'
  }
  let hash = 0
  for (let i = 0; i < name.length; i++) {
    hash += name.codePointAt(i) ?? 0
  }
  return AVATAR_BG_COLORS[hash % AVATAR_BG_COLORS.length] || '#909399'
}