gaoluyang
2 天以前 787ccc59ba89bacc075562a161ecf02bc76ebadc
src/views/im/home/components/picker/friend-picker-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,245 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Input, message } from 'ant-design-vue'
import { PagedScroller } from '..'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
import { useSelectedItems } from '../../composables/useSelectedItems'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendPickerPanel' })
const props = withDefaults(
  defineProps<{
    /** ç¦ç”¨ id:列表里展示置灰、不可勾选、不计入已选数(典型:邀请入群时已在群成员) */
    disabledIds?: number[]
    /** å…¨é‡å¥½å‹åˆ—表 */
    friends: FriendLite[]
    /** éšè— id:不展示(hide > locked > disabled) */
    hideIds?: number[]
    /** é”å®š id:默认勾选、不可取消、计入已选数(典型:私聊侧 +建群锁定对方) */
    lockedIds?: number[]
    /** å·²é€‰æ•°ä¸Šé™ï¼›ä¸ä¼ æˆ– <=0 æ—¶ä¸é™ */
    maxSize?: number
    /** å·²é€‰å¥½å‹ id(v-model);按数组顺序即为点击顺序 */
    selectedIds: number[]
  }>(),
  {
    lockedIds: () => [],
    disabledIds: () => [],
    hideIds: () => [],
    maxSize: 0
  }
)
const emit = defineEmits<{
  'update:selectedIds': [value: number[]]
}>()
const keyword = ref('')
/** id â†’ friend æ˜ å°„,已选反查 / ä¸‰æ€åˆ¤å®šå…±ç”¨ï¼Œé¿å…æ¯æ¬¡ O(N) æ‰« */
const byId = computed(() => {
  const map = new Map<number, FriendLite>()
  for (const friend of props.friends) {
    map.set(friend.id, friend)
  }
  return map
})
/** ä¸‰æ€ id é›†åˆï¼šæ¯æ¬¡è¿‡æ»¤å¤ç”¨ */
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** å€™é€‰å¥½å‹ï¼šå‰”除 hideIds(hide ä¼˜å…ˆçº§æœ€é«˜ï¼‰ */
const candidates = computed(() =>
  props.friends.filter((friend) => !hideSet.value.has(friend.id))
)
/** å§”托 useFriendBuckets:搜索规则复用,左侧列表按滚动分页渲染 */
const { filtered } = useFriendBuckets(candidates, keyword)
/** å·²é€‰æ•° + å·²é€‰å¥½å‹åˆ—表:三态优先级 + é¡ºåºæ‹¼æŽ¥ç”± useSelectedItems ç»Ÿä¸€æ‰¿æ‹… */
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
  () => props.selectedIds,
  () => props.lockedIds,
  () => props.disabledIds,
  () => props.hideIds,
  byId
)
/** æ˜¯å¦è¢«é”å®š */
function isLocked(friend: FriendLite): boolean {
  return lockedSet.value.has(friend.id)
}
/** æ˜¯å¦è¢«ç¦ç”¨ï¼šlocked / hide å·²è¢«å‰ç½®è¿‡æ»¤ï¼Œå‰©ä¸‹çš„æ‰ç®— disabled */
function isDisabled(friend: FriendLite): boolean {
  return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id)
}
/** æ˜¯å¦é€‰ä¸­ï¼šlocked è§†ä¸ºæ°¸è¿œé€‰ä¸­ */
function isSelected(friend: FriendLite): boolean {
  return selectedSet.value.has(friend.id)
}
/** åœ†å½¢å‹¾é€‰æŒ‡ç¤ºå™¨çš„ class:选中 / é”å®šèµ°ç»¿åº•,禁用灰底,未选空心圆 */
function getCheckClass(friend: FriendLite): string {
  if (isLocked(friend) || isSelected(friend)) {
    return 'bg-[#07c160] border border-solid border-[#07c160]'
  }
  if (isDisabled(friend)) {
    return 'bg-[var(--ant-color-fill)] border border-solid border-[var(--ant-color-border)]'
  }
  return 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
}
/** åˆ‡æ¢é€‰ä¸­æ€ï¼šlocked / disabled ä¸å“åº”;右栏 Ã— ç§»é™¤ / è¡Œ click éƒ½èµ°è¿™é‡Œ */
function handleToggle(friend: FriendLite) {
  if (isLocked(friend) || isDisabled(friend)) {
    return
  }
  const next = [...props.selectedIds]
  const index = next.indexOf(friend.id)
  if (index === -1) {
    if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
      message.error(`最多选择 ${props.maxSize} ä½å¥½å‹`)
      return
    }
    next.push(friend.id)
  } else {
    next.splice(index, 1)
  }
  emit('update:selectedIds', next)
}
</script>
<template>
  <!--
    å¥½å‹é€‰æ‹©é¢æ¿ï¼šç”¨äºŽã€Œæ–°å»ºç¾¤èŠ / é‚€è¯·å¥½å‹ / æŽ¨èæ—¶åˆ›å»ºèŠå¤©ã€ç­‰å¥½å‹é€‰æ‹©åœºæ™¯
    - å·¦ï¼šæœç´¢ + å¥½å‹åˆ—表(圆形勾选)
    - å³ï¼šå·²é€‰æ•°æ ‡é¢˜ + å·²é€‰å¥½å‹åˆ—表(按点击顺序)
    - Panel ä¸å¸¦ el-dialog å£³ï¼›dialog ç”±ä¸šåŠ¡å£³æŒæœ‰
    - ä¸‰æ€è¯­ä¹‰ï¼šhide > locked > disabled(详见 contract)
  -->
  <div class="flex h-full">
    <!-- å·¦æ  -->
    <div
      class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]"
    >
      <!-- æœç´¢æ¡† -->
      <div class="flex-shrink-0 px-3 py-2">
        <Input v-model:value="keyword" placeholder="搜索好友" allow-clear>
          <template #prefix>
            <Icon icon="ant-design:search-outlined" />
          </template>
        </Input>
      </div>
      <div class="flex-1 min-h-0">
        <PagedScroller v-if="filtered.length > 0" :items="filtered" :page-size="30" item-key="id">
          <template #default="{ item }">
            <div
              :key="(item as FriendLite).id"
              class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--ant-color-fill)]"
              :class="{
                'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(item as FriendLite)
              }"
              @click="handleToggle(item as FriendLite)"
            >
              <!-- åœ†å½¢å‹¾é€‰æŒ‡ç¤ºå™¨ï¼šæœªé€‰ç°è‰²ç©ºå¿ƒåœ†ï¼Œé€‰ä¸­å®žå¿ƒå¾®ä¿¡ç»¿ + ç™½å¯¹å‹¾ï¼›é”å®š / ç¦ç”¨èµ°ç°åº• -->
              <span
                class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
                :class="getCheckClass(item as FriendLite)"
              >
                <Icon
                  v-if="isSelected(item as FriendLite) || isLocked(item as FriendLite)"
                  icon="ant-design:check-outlined"
                  :size="12"
                  color="#fff"
                />
              </span>
              <UserAvatar
                :id="(item as FriendLite).id"
                :url="(item as FriendLite).avatar"
                :name="(item as FriendLite).nickname"
                :size="36"
                :clickable="false"
              />
              <!-- è¡Œå†…名字:备注优先,列表里不重复展示昵称 -->
              <span
                class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
              >
                {{ (item as FriendLite).displayName || (item as FriendLite).nickname }}
              </span>
            </div>
          </template>
        </PagedScroller>
        <div v-else class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]">
          {{ keyword ? '没有匹配的好友' : '暂无好友' }}
        </div>
      </div>
    </div>
    <!-- å³æ  -->
    <div class="flex flex-col flex-1 min-w-0">
      <!-- æ ‡é¢˜ï¼šå·²é€‰æ•°ï¼›é«˜åº¦å¯¹é½å·¦ä¾§ input default(32px),保证两侧第一项起点同水平 -->
      <div
        class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
      >
        å·²é€‰æ‹© {{ selectedCount }} ä¸ªå¥½å‹
      </div>
      <!-- å·²é€‰é¢„览:按 selectedIds + lockedIds æ•°ç»„顺序(点击顺序)展示 -->
      <div class="flex-1">
        <div
          v-for="friend in selectedFriends"
          :key="friend.id"
          class="flex gap-2.5 items-center px-4 py-2"
        >
          <UserAvatar
            :id="friend.id"
            :url="friend.avatar"
            :name="friend.nickname"
            :size="36"
            :clickable="false"
          />
          <span
            class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
          >
            {{ friend.displayName || friend.nickname }}
          </span>
          <!-- é”å®šé¡¹ä¸æ¸²æŸ“ Ã—,避免误以为可移除 -->
          <Icon
            v-if="!isLocked(friend)"
            icon="ant-design:close-outlined"
            :size="14"
            class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
            @click="handleToggle(friend)"
          />
        </div>
        <div
          v-if="selectedFriends.length === 0"
          class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
        >
          è¯·ä»Žå·¦ä¾§é€‰æ‹©å¥½å‹
        </div>
      </div>
      <!-- ä¸šåŠ¡å£³å¡žé¢å¤–å†…å®¹çš„ä½ç½®ï¼›FriendPickerPanel ä¸»æµåœºæ™¯ä¸éœ€è¦ footer -->
      <div
        v-if="$slots.footer"
        class="flex-shrink-0 border-t border-t-solid border-[var(--ant-color-border-secondary)]"
      >
        <slot name="footer"></slot>
      </div>
    </div>
  </div>
</template>