gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/input/mention-picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,207 @@
<script lang="ts" setup>
import { computed, ref, useTemplateRef, watch } from 'vue'
import { CommonStatusEnum } from '#/packages/constants/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '#/views/im/utils/constants'
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
defineOptions({ name: 'ImMentionPicker' })
const props = withDefaults(
  defineProps<{
    canAtAll?: boolean // å½“前用户是否能 @ å…¨å‘˜ï¼ˆç¾¤ä¸» / ç®¡ç†å‘˜ï¼‰ï¼Œçˆ¶ç»„件按角色算好传入
    members: GroupMemberLite[] // å½“前群的成员列表
    // æµ®å±‚位置:x æ¨ªåæ ‡ + top / bottom äºŒé€‰ä¸€ï¼ˆbottom é”šå®šæ—¶ picker ä¸‹æ²¿è´´ @ ä¸Šæ–¹ï¼‰
    position?: { bottom?: number; top?: number; x: number; }
    searchText?: string // @ åŽè¾“入的过滤文本
    visible: boolean // æ˜¯å¦æ˜¾ç¤º
  }>(),
  {
    searchText: '',
    position: () => ({ x: 0, bottom: 0 })
  }
)
const emit = defineEmits<{
  select: [member: GroupMemberLite]
  'update:visible': [value: boolean]
}>()
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef')
const activeIdx = ref(0)
/** å½“前登录用户 id(成员列表过滤掉自己) */
const selfUserId = computed(() => getCurrentUserId())
/**
 * è™šæ‹Ÿ"所有人"项:群主 / ç®¡ç†å‘˜ï¼ˆcanAtAll=true)+ å…³é”®å­—命中"所有人"前缀时存在
 *
 * MessageInput èµ° token data-id æ”¶é›† atUserIds,不依赖文案字符串;
 * è¿™é‡Œçš„ userId / æ–‡æ¡ˆéƒ½ä»Ž im/utils/constants å–,避免散落
 */
const allItem = computed<GroupMemberLite | null>(() => {
  if (!props.canAtAll) {
    return null
  }
  if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) {
    return null
  }
  // @所有人 æ˜¯ä¸ªä¼ªæˆå‘˜ï¼Œnickname ç»™ IM_AT_ALL_NICKNAME è®©å¤´åƒ :name è¡Œä¸ºå¯¹é½æ™®é€šæˆå‘˜
  return {
    userId: IM_AT_ALL_USER_ID,
    showName: IM_AT_ALL_NICKNAME,
    nickname: IM_AT_ALL_NICKNAME
  }
})
/** çœŸæˆå‘˜ï¼šè¿‡æ»¤è‡ªå·± / é€€ç¾¤ / ä¸åŒ¹é…å…³é”®å­—;不截断数量,浮层 max-height + el-scrollbar æ’‘滚动 */
const memberItems = computed<GroupMemberLite[]>(() =>
  props.members.filter(
    (member) =>
      member.userId !== selfUserId.value &&
      member.status !== CommonStatusEnum.DISABLE &&
      !!member.showName &&
      member.showName.startsWith(props.searchText)
  )
)
/** é”®ç›˜å¯¼èˆªä¸Ž pickActive èµ°çš„æ‰å¹³åˆ—表,allItem åœ¨å‰ã€memberItems åœ¨åŽ */
const showMembers = computed<GroupMemberLite[]>(() => {
  return allItem.value ? [allItem.value, ...memberItems.value] : memberItems.value
})
/** å€™é€‰åˆ—表变化(用户输入关键词在过滤)→ é‡ç½®é«˜äº®åˆ°é¦–项 + æ»šå›žé¡¶ */
watch(showMembers, (list) => {
  activeIdx.value = list.length > 0 ? 0 : -1
  scrollToTop()
})
/** æµ®å±‚重新打开 â†’ é‡ç½®é«˜äº® + æ»šå›žé¡¶ï¼ˆé¿å…ä¸Šæ¬¡çš„中间状态残留) */
watch(
  () => props.visible,
  (v) => {
    if (v) {
      activeIdx.value = showMembers.value.length > 0 ? 0 : -1
      scrollToTop()
    }
  }
)
/** el-scrollbar æ²¡æš´éœ² scrollTo,直接拿内部 wrap è°ƒ scrollTop */
function scrollToTop() {
  const scrollWrap = scrollRef.value
  if (scrollWrap) {
    scrollWrap.scrollTop = 0
  }
}
/** é”®ç›˜ä¸Šä¸‹å¯¼èˆªæ—¶æŠŠé«˜äº®é¡¹æ»šåˆ°å¯è§†åŒºï¼šè¶…出底边下推、超出顶边上拉,否则不动 */
function scrollToActive() {
  const scrollWrap = scrollRef.value
  if (!scrollWrap) {
    return
  }
  const itemHeight = 40
  const activeOffsetTop = activeIdx.value * itemHeight
  if (activeOffsetTop + itemHeight > scrollWrap.scrollTop + scrollWrap.clientHeight) {
    scrollWrap.scrollTop = activeOffsetTop + itemHeight - scrollWrap.clientHeight
  } else if (activeOffsetTop < scrollWrap.scrollTop) {
    scrollWrap.scrollTop = activeOffsetTop
  }
}
/** é€‰ä¸­ä¸€é¡¹ï¼šemit ç»™ MessageInput è½ token,同时关掉浮层 */
function handleSelect(member: GroupMemberLite) {
  emit('select', member)
  emit('update:visible', false)
}
// æš´éœ²ç»™çˆ¶ç»„件的键盘导航方法
defineExpose({
  moveUp() {
    if (activeIdx.value > 0) {
      activeIdx.value--
      scrollToActive()
    }
  },
  moveDown() {
    if (activeIdx.value < showMembers.value.length - 1) {
      activeIdx.value++
      scrollToActive()
    }
  },
  pickActive() {
    const member = showMembers.value[activeIdx.value]
    if (activeIdx.value >= 0 && member) {
      handleSelect(member)
    }
  },
  hasCandidates: () => showMembers.value.length > 0
})
</script>
<template>
  <!--
    @ æˆå‘˜é€‰æ‹©æµ®å±‚(对齐微信 PC:所有人在顶 + ç¾¤æˆå‘˜åˆ†ç»„ + åº•部三角指针)
    - çˆ¶ç»„件通过 v-model:visible æŽ§åˆ¶æ˜¾éšï¼ŒsearchText è¿‡æ»¤
    - çˆ¶ç»„件通过 ref è°ƒ moveUp / moveDown / pickActive å®žçŽ°é”®ç›˜ä¸Šä¸‹ + Enter é€‰ä¸­
    - @select å‘射被选中的成员
  -->
  <div
    v-show="visible && showMembers.length > 0"
    class="message-input__mention-picker !fixed z-100 w-50 rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
    :style="{
      left: `${position.x }px`,
      top: position.top != null ? `${position.top }px` : 'auto',
      bottom: position.bottom != null ? `${position.bottom }px` : 'auto'
    }"
  >
    <div ref="scrollRef" style="max-height: 300px; overflow-y: auto">
      <!-- æ‰€æœ‰äººï¼šè™šæ‹Ÿé¡¹ï¼Œä»…群主可选;蓝色方块 + ç¾¤ä½“图标,跟下面的成员头像区分 -->
      <div
        v-if="allItem"
        class="flex items-center gap-2.5 px-[5px] h-10 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
        :class="{ 'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': activeIdx === 0 }"
        @click.stop="handleSelect(allItem)"
      >
        <div
          class="flex items-center justify-center w-[30px] h-[30px] rounded text-white bg-[var(--ant-color-primary)] flex-shrink-0"
        >
          <Icon icon="ep:user-filled" :size="18" />
        </div>
        <span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
          {{ allItem.showName }}
        </span>
      </div>
      <!-- ç¾¤æˆå‘˜ section header:只有"所有人 + çœŸæˆå‘˜"两边都在时才出现,避免单一来源时显得多余 -->
      <div
        v-if="allItem && memberItems.length > 0"
        class="px-2 pt-2 pb-1 text-12px text-[var(--ant-color-text-secondary)]"
      >
        ç¾¤æˆå‘˜
      </div>
      <!-- çœŸæˆå‘˜è¡Œ -->
      <GroupMember
        v-for="(member, idx) in memberItems"
        :key="member.userId"
        :member="member"
        :height="40"
        :active="activeIdx === (allItem ? idx + 1 : idx)"
        :clickable="false"
        @click.stop="handleSelect(member)"
      />
    </div>
    <!-- ä¸‰è§’指针:picker åœ¨ @ ä¸‹æ–¹ï¼ˆposition.top é”šå®šï¼‰â†’ ç®­å¤´æœä¸Šè´´é¡¶ï¼›picker åœ¨ @ ä¸Šæ–¹ï¼ˆposition.bottom é”šå®šï¼‰â†’ ç®­å¤´æœä¸‹è´´åº• -->
    <div
      class="absolute left-4 w-3 h-3 rotate-45 bg-[var(--ant-color-bg-container)]"
      :class="position.top != null ? '-top-1.5' : '-bottom-1.5'"
    ></div>
  </div>
</template>