gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
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>