| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |