gaoluyang
14 小时以前 cb9cd49627b65a4c0e137e08063271a8cefe1826
src/views/im/home/components/picker/conversation-picker-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,370 @@
<script lang="ts" setup>
import type { Conversation } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Input, message } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { GroupAvatar } from '../group'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImConversationPickerPanel' })
const props = withDefaults(
  defineProps<{
    /** å…¨é‡ä¼šè¯åˆ—表 */
    conversations: Conversation[]
    /** éšè— key:从候选 / å·²é€‰ / æœ€è¿‘转发里都剔除(不能转发回自己、推荐名片自身的会话等) */
    hideKeys?: string[]
    /** å·²é€‰æ•°ä¸Šé™ï¼›ä¸ä¼ æˆ– <=0 æ—¶ä¸é™ */
    maxSize?: number
    /** æœ€è¿‘转发会话 key åˆ—表;展示在左栏顶部横向头像区 */
    recentForwardConversationKeys?: string[]
    /** å·²é€‰ä¼šè¯ key(v-model);key ç”± getConversationKey ç”Ÿæˆ */
    selectedKeys: string[]
    /** æ˜¯å¦å±•示「创建聊天」入口 */
    showCreateChat?: boolean
  }>(),
  {
    recentForwardConversationKeys: () => [],
    hideKeys: () => [],
    maxSize: 0,
    showCreateChat: false
  }
)
const emit = defineEmits<{
  createChat: []
  /** ç”¨æˆ·åœ¨ã€Œæœ€è¿‘转发」段进入移除模式后点 Ã—;业务壳收到后调 conversationStore.removeRecentForwardConversationKey è½ç›˜ */
  removeRecent: [key: string]
  'update:selectedKeys': [value: string[]]
}>()
const keyword = ref('')
const recentRemoveMode = ref(false) // ã€Œæœ€è¿‘转发」段是否处于移除模式:true æ—¶å¤´åƒå³ä¸Šè§’变 Ã— ä¸å†åˆ‡å‹¾é€‰
/** å…¨é‡ä¼šè¯çš„ key→Conversation æ˜ å°„,已选 / æœ€è¿‘转发反查共用,避免每次 O(N) æ‰« */
const byKey = computed(() => {
  const map = new Map<string, Conversation>()
  for (const conversation of props.conversations) {
    map.set(getConversationKey(conversation), conversation)
  }
  return map
})
/** éšè—é›†åˆï¼šæ¯æ¬¡è¿‡æ»¤å¤ç”¨ */
const hideSet = computed(() => new Set(props.hideKeys))
/** å·²é€‰é›†åˆï¼šåœ†å½¢æŒ‡ç¤ºå™¨ isSelected èµ° set å¿«æŸ¥ */
const selectedSet = computed(() => new Set(props.selectedKeys))
/** å€™é€‰ä¼šè¯ï¼šå‰”除 hideKeys */
const candidateConversations = computed(() =>
  props.conversations.filter((c) => !hideSet.value.has(getConversationKey(c)))
)
/** å·¦æ å±•示列表:在候选基础上按 keyword è¿‡æ»¤ */
const shownConversations = computed(() =>
  filterConversationsByKeyword(candidateConversations.value, keyword.value)
)
/** æœ€è¿‘转发的会话对象列表:从 recentForwardConversationKeys åæŸ¥ï¼›å‰”除 hide / ä¸å­˜åœ¨çš„ key */
const recentForwardConversations = computed(() =>
  props.recentForwardConversationKeys
    .map((key) => byKey.value.get(key))
    .filter((c): c is Conversation => c != null && !hideSet.value.has(getConversationKey(c)))
)
/** æ˜¯å¦å±•示「最近转发」段:keyword ä¸ºç©º + æœ‰æ•°æ®æ—¶æ‰å±•示,搜索时让位 */
const showRecentSection = computed(
  () => !keyword.value.trim() && recentForwardConversations.value.length > 0
)
/** å·²é€‰ä¼šè¯åˆ—表:按 selectedKeys æ•°ç»„顺序(即点击顺序)反查;过滤 hideSet é¿å…çˆ¶ç»„件动态隐藏的会话仍在右侧渲染 / æäº¤ */
const selectedConversations = computed(() =>
  props.selectedKeys
    .map((key) => byKey.value.get(key))
    .filter(
      (conversation): conversation is Conversation =>
        conversation != null && !hideSet.value.has(getConversationKey(conversation))
    )
)
/** å³æ æ ‡é¢˜æ–‡æ¡ˆï¼šå•选「发送给」、多选「分别发送给」 */
const sendTitle = computed(() => (props.selectedKeys.length > 1 ? '分别发送给' : '发送给'))
/** æ˜¯å¦å·²é€‰ä¸­ï¼šå·¦æ åœ†å½¢æŒ‡ç¤ºå™¨ / æœ€è¿‘转发头像角标共用 */
function isSelected(conversation: Conversation): boolean {
  return selectedSet.value.has(getConversationKey(conversation))
}
/** ã€Œæœ€è¿‘转发」头像点击:移除模式下不切勾选(移除由 Ã— è§’标处理) */
function handleRecentTileClick(conversation: Conversation) {
  if (recentRemoveMode.value) {
    return
  }
  handleToggle(conversation)
}
/** åˆ‡æ¢é€‰ä¸­æ€ï¼šå·¦æ  row / æœ€è¿‘转发头像 / å³æ  Ã— ç§»é™¤éƒ½èµ°è¿™é‡Œ */
function handleToggle(conversation: Conversation) {
  const key = getConversationKey(conversation)
  const next = [...props.selectedKeys]
  const index = next.indexOf(key)
  if (index === -1) {
    // çˆ¶ç»„件标记隐藏的会话即便有路径触达也不应入选
    if (hideSet.value.has(key)) {
      return
    }
    if (props.maxSize > 0 && next.length >= props.maxSize) {
      message.error(`最多选择 ${props.maxSize} ä¸ªä¼šè¯`)
      return
    }
    next.push(key)
  } else {
    next.splice(index, 1)
  }
  emit('update:selectedKeys', next)
}
</script>
<template>
  <!--
    ä¼šè¯é€‰æ‹©é¢æ¿ï¼šç”¨äºŽæŽ¨èåç‰‡ / è½¬å‘消息等"选已有会话"场景
    - å·¦ï¼šæœç´¢ + æœ€è¿‘转发横向头像 + åˆ›å»ºèŠå¤©å…¥å£ + æœ€è¿‘聊天列表(圆形勾选)
    - å³ï¼šå·²é€‰æ•°æ ‡é¢˜ + å·²é€‰ä¼šè¯åˆ—表(按点击顺序)+ footer slot
    - Panel ä¸å¸¦ el-dialog å£³ï¼›dialog ç”±ä¸šåŠ¡å£³æŒæœ‰
    - footer slot æ¸²æŸ“在右栏已选列表下方,业务壳放预览卡 / ç•™è¨€ / æäº¤æŒ‰é’®
  -->
  <div class="flex h-full">
    <!-- å·¦æ  -->
    <div
      class="flex flex-col w-[280px] 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">
        <!-- æœ€è¿‘转发横向头像区:keyword ä¸ºç©º + æœ‰æœ€è¿‘转发数据时展示 -->
        <template v-if="showRecentSection">
          <div class="flex justify-between items-center pl-3 pr-2 pb-1.5">
            <span class="text-13px text-[var(--ant-color-text-secondary)]">最近转发</span>
            <span
              class="px-1 cursor-pointer text-13px text-[var(--ant-color-primary)] hover:opacity-80"
              @click="recentRemoveMode = !recentRemoveMode"
            >
              {{ recentRemoveMode ? '完成' : '移除' }}
            </span>
          </div>
          <div
            class="flex gap-2 pl-3 pr-2 pt-1 pb-2 overflow-x-auto im-conversation-picker__recent"
          >
            <div
              v-for="conversation in recentForwardConversations"
              :key="getConversationKey(conversation)"
              class="flex flex-col flex-shrink-0 gap-1 items-center"
              :class="{ 'cursor-pointer': !recentRemoveMode }"
              @click="handleRecentTileClick(conversation)"
            >
              <div class="relative">
                <GroupAvatar
                  v-if="conversation.type === ImConversationType.GROUP"
                  :group-id="conversation.targetId"
                  :url="conversation.avatar"
                  :name="conversation.name"
                  :size="36"
                />
                <UserAvatar
                  v-else
                  :url="conversation.avatar"
                  :name="conversation.name"
                  :size="36"
                  :clickable="false"
                />
                <!-- ç§»é™¤æ¨¡å¼ï¼šå³ä¸Šè§’ Ã— åœ†è§’标,点击把这条 key ä»Ž recentForwardConversationKeys åˆ æŽ‰ -->
                <span
                  v-if="recentRemoveMode"
                  class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full cursor-pointer bg-[var(--ant-color-fill-dark)] text-[var(--ant-color-text)]"
                  @click.stop="emit('removeRecent', getConversationKey(conversation))"
                >
                  <Icon icon="ant-design:close-outlined" :size="10" />
                </span>
                <!-- éžç§»é™¤æ¨¡å¼ï¼šå³ä¸Šè§’圆形勾选指示器;未选灰空心圈、选中绿底白对勾 -->
                <span
                  v-else
                  class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full transition-colors"
                  :class="
                    isSelected(conversation)
                      ? 'bg-[#07c160] border border-solid border-[#07c160]'
                      : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
                  "
                >
                  <Icon
                    v-if="isSelected(conversation)"
                    icon="ant-design:check-outlined"
                    :size="10"
                    color="#fff"
                  />
                </span>
              </div>
              <span
                class="overflow-hidden max-w-[48px] text-12px truncate text-[var(--ant-color-text)]"
              >
                {{ conversation.name }}
              </span>
            </div>
          </div>
        </template>
        <!-- åˆ›å»ºèŠå¤©å…¥å£ï¼škeyword ä¸ºç©º + showCreateChat=true æ—¶å±•示 -->
        <div
          v-if="showCreateChat && !keyword.trim()"
          class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--ant-color-fill)]"
          @click="emit('createChat')"
        >
          <span
            class="flex flex-shrink-0 justify-center items-center w-8 h-8 rounded-full bg-[var(--ant-color-fill)] text-[var(--ant-color-text-secondary)]"
          >
            <Icon icon="ant-design:plus-outlined" :size="16" />
          </span>
          <span class="text-sm text-[var(--ant-color-text)]">创建聊天</span>
        </div>
        <!-- æœ€è¿‘聊天分组标题 -->
        <div class="px-3 pb-1.5 text-13px text-[var(--ant-color-text-secondary)]">最近聊天</div>
        <!-- ä¼šè¯åˆ—表 -->
        <div
          v-for="conversation in shownConversations"
          :key="getConversationKey(conversation)"
          class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--ant-color-fill)]"
          @click="handleToggle(conversation)"
        >
          <!-- åœ†å½¢å‹¾é€‰æŒ‡ç¤ºå™¨ï¼šæœªé€‰ç°è‰²ç©ºå¿ƒåœ†ï¼Œé€‰ä¸­å®žå¿ƒå¾®ä¿¡ç»¿ + ç™½å¯¹å‹¾ -->
          <span
            class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
            :class="
              isSelected(conversation)
                ? 'bg-[#07c160] border border-solid border-[#07c160]'
                : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
            "
          >
            <Icon
              v-if="isSelected(conversation)"
              icon="ant-design:check-outlined"
              :size="12"
              color="#fff"
            />
          </span>
          <GroupAvatar
            v-if="conversation.type === ImConversationType.GROUP"
            :group-id="conversation.targetId"
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
          />
          <UserAvatar
            v-else
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
            :clickable="false"
          />
          <span
            class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
          >
            {{ conversation.name }}
          </span>
        </div>
        <!-- ç©ºæ€ -->
        <div
          v-if="shownConversations.length === 0"
          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">
      <!-- æ ‡é¢˜ï¼šé€‰ 0/1「发送给」、多个「分别发送给」(与微信文案一致) -->
      <div
        class="flex-shrink-0 px-4 py-3 border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
      >
        {{ sendTitle }}
      </div>
      <!-- å·²é€‰é¢„览:按 selectedKeys æ•°ç»„顺序(点击顺序)展示 -->
      <div class="flex-1">
        <div
          v-for="conversation in selectedConversations"
          :key="getConversationKey(conversation)"
          class="flex gap-2.5 items-center px-4 py-2"
        >
          <GroupAvatar
            v-if="conversation.type === ImConversationType.GROUP"
            :group-id="conversation.targetId"
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
          />
          <UserAvatar
            v-else
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
            :clickable="false"
          />
          <span
            class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
          >
            {{ conversation.name }}
          </span>
          <Icon
            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(conversation)"
          />
        </div>
        <div
          v-if="selectedConversations.length === 0"
          class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
        >
          ä»Žå·¦ä¾§é€‰æ‹©å¥½å‹æˆ–群聊
        </div>
      </div>
      <!-- ä¸šåŠ¡å£³å¡žé¢„è§ˆå¡ / ç•™è¨€ / æäº¤æŒ‰é’®çš„位置 -->
      <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>
<style scoped>
/* æ¨ªå‘滚动条做窄一点避免占视觉;走 ::-webkit-scrollbar æµè§ˆå™¨ä¼ªå…ƒç´  */
.im-conversation-picker__recent::-webkit-scrollbar {
  height: 4px;
}
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
  background-color: var(--ant-color-border);
  border-radius: 2px;
}
</style>